Files
group_fqcd_jr/tools/load_knowledge_milvus.py
T
lzf_0626 7b3a72860c feat(knowledge): 为 C1-C5 各补一条「能买什么产品」的问答
客户实测反馈:「C1 客户能买什么」被引导到人工客服,而知识库里其实有答案。
实测分数:这条问句 top1 只有 0.5633、与次优差 0.0236(低于 0.07 门槛)→ 转人工;
而「C1 保守型客户可以买哪些风险等级的产品」是 0.7794,过了 0.75 硬门槛、能答。
根因是客户与知识库的用词鸿沟:客户说「C1 客户」,知识块标题写的是「C1 保守型」。
短问法少了"保守型"这个锚点就差 0.19 分——而客户不知道 C1 就等于保守型,这正是他要问的。

按 A 方案(数据问题用数据解决)为 C1-C5 各补一条 FAQ,答案全部取自
《个人投资者适当性管理指南》原文,不自行编写:
- 第十二条投资者与产品匹配矩阵(各级别可购买的产品风险等级)
- 第十四条硬匹配规则的跨级禁止要求
- 第十五条豁免规则(C3 买 R4、C4 买 R5 的签署揭示书与持仓上限)

知识块 631 → 636。同时修正 load 脚本自检里过时的期望:这句话现在命中 FAQ 而非
POL-AST(两者是同一份内容,只是 FAQ 的问句措辞更接近客户口语)。

验证:C1-C5 六个等级的「能买什么」问法全部直接回答、无一转人工;
ruff / mypy / 468 unit+contract 全绿。
2026-09-10 22:34:20 +08:00

187 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把 knowledge/_chunks.jsonl 灌入 Milvus 三个知识集合(临时脚本,跑完即删)。
设计要点:
1. **集合 schema** 按方案 §4.2 的统一字段(10 项),另加 5 个检索与合规必需的字段:
`chapter`/`section`(定位)、`source_file`(溯源)、`doc_no`(内部文件编号)、
`visibility`(反洗钱手册标 internal,客服侧按此过滤)。维度 1024 与
`qwen3.7-text-embedding-flash` 实测一致,索引按方案 §2.4.4:IVF_FLAT + COSINE + nlist=128。
2. **向量输入用「标题 + 正文」**而不是只喂正文:标题里带条款号与章节名(如
「第五章 … 第十三条 … 第一类:资金流转异常」),是比正文更干净的检索信号。
3. **幂等**:用 upsert,同一 doc_id 重复灌库不会产生重复行,可以反复重跑。
4. 灌完立刻做检索自检(拿几个真实客户问题去查),不看自检结果不算灌成功。
"""
import asyncio
import json
import os
from collections import defaultdict
from pathlib import Path
import httpx
from dotenv import load_dotenv
from pymilvus import DataType, MilvusClient
load_dotenv(override=False)
MILVUS_URI = os.environ.get("MILVUS_URI", "http://127.0.0.1:19530")
MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN") or None
EMBED_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
EMBED_MODEL = "qwen3.7-text-embedding-flash"
EMBED_KEY = os.environ.get("QWEN_EMBEDDING_API_KEY", "")
DIM = 1024
BATCH = 10
COLLECTIONS = ["fin_faq_collection", "fin_product_collection", "fin_policy_collection"]
# 检索自检用例:(自然语言问题, 期望命中的 doc_id 前缀)
CHECKS = [
("基金赎回到账需要多长时间", "FAQ-0016"),
# 分等级的「能买什么」现在由 FAQ 承接(与 POL-AST 的匹配矩阵是同一份内容,
# 但 FAQ 的问句措辞更接近客户口语,所以问这句话时 FAQ 会排在前面)。
("C1 保守型客户可以买哪些风险等级的产品", "FAQ"),
("C1 客户能买什么", "FAQ"),
("南方季季盈90天的起投金额是多少", "PROD"),
("开户需要准备哪些材料", "FAQ-0021"),
("业绩比较基准是什么意思", "FAQ-0019"),
("高净值客户能享受什么费率优惠", "HNW"),
]
def build_schema(client: MilvusClient) -> object:
schema = MilvusClient.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("doc_id", DataType.VARCHAR, max_length=64, is_primary=True)
schema.add_field("title", DataType.VARCHAR, max_length=1024)
schema.add_field("content", DataType.VARCHAR, max_length=16384)
schema.add_field("chapter", DataType.VARCHAR, max_length=512)
schema.add_field("section", DataType.VARCHAR, max_length=512)
schema.add_field("tags", DataType.VARCHAR, max_length=512)
schema.add_field("doc_no", DataType.VARCHAR, max_length=64)
schema.add_field("version", DataType.VARCHAR, max_length=32)
schema.add_field("effective_date", DataType.VARCHAR, max_length=32)
schema.add_field("expire_date", DataType.VARCHAR, max_length=32)
schema.add_field("source_url", DataType.VARCHAR, max_length=512)
schema.add_field("reviewer", DataType.VARCHAR, max_length=64)
schema.add_field("source_file", DataType.VARCHAR, max_length=128)
schema.add_field("visibility", DataType.VARCHAR, max_length=16)
schema.add_field("embedding", DataType.FLOAT_VECTOR, dim=DIM)
return schema
def ensure_collections(client: MilvusClient) -> None:
existing = set(client.list_collections())
for name in COLLECTIONS:
if name in existing:
print(f" 集合已存在,跳过创建:{name}")
continue
index_params = client.prepare_index_params()
index_params.add_index(
field_name="embedding", index_type="IVF_FLAT", metric_type="COSINE",
params={"nlist": 128},
)
client.create_collection(
collection_name=name, schema=build_schema(client), index_params=index_params
)
print(f" 已创建集合:{name}")
async def embed(texts: list[str]) -> list[list[float]]:
async with httpx.AsyncClient(timeout=90) as client:
response = await client.post(
f"{EMBED_BASE}/embeddings",
headers={"Authorization": f"Bearer {EMBED_KEY}"},
json={"model": EMBED_MODEL, "input": texts},
)
response.raise_for_status()
payload = response.json()
items = sorted(payload["data"], key=lambda item: item["index"])
return [item["embedding"] for item in items]
async def main() -> None:
if not EMBED_KEY:
print("缺少 QWEN_EMBEDDING_API_KEY")
return
records = [
json.loads(line)
for line in (Path("knowledge") / "_chunks.jsonl").read_text(encoding="utf-8").splitlines()
if line.strip()
]
print(f"待入库块数:{len(records)}")
client = MilvusClient(uri=MILVUS_URI, token=MILVUS_TOKEN)
print("\n== 建集合 ==")
ensure_collections(client)
grouped: dict[str, list[dict[str, object]]] = defaultdict(list)
for record in records:
grouped[str(record["collection"])].append(record)
print("\n== 生成向量并写入 ==")
for name, group in grouped.items():
rows: list[dict[str, object]] = []
for start in range(0, len(group), BATCH):
batch = group[start:start + BATCH]
vectors = await embed(
[f"{record['title']}\n{record['content']}" for record in batch]
)
for record, vector in zip(batch, vectors, strict=True):
rows.append({
"doc_id": record["doc_id"],
"title": str(record["title"])[:500],
"content": str(record["content"])[:8000],
"chapter": str(record["chapter"])[:250],
"section": str(record["section"])[:250],
"tags": str(record["tags"])[:250],
"doc_no": str(record["doc_no"])[:60],
"version": str(record["version"])[:30],
"effective_date": str(record["effective_date"])[:30],
"expire_date": str(record["expire_date"])[:30],
"source_url": str(record["source_url"])[:500],
"reviewer": str(record["reviewer"])[:60],
"source_file": str(record["source_file"])[:120],
"visibility": str(record["visibility"])[:16],
"embedding": vector,
})
print(f" {name}: 已向量化 {min(start + BATCH, len(group))}/{len(group)}")
client.upsert(collection_name=name, data=rows)
client.flush(collection_name=name)
print("\n== 各集合条目数 ==")
for name in COLLECTIONS:
stats = client.get_collection_stats(collection_name=name)
print(f" {name}: {stats.get('row_count')} 条")
print("\n== 检索自检 ==")
queries = [question for question, _ in CHECKS]
query_vectors = await embed(queries)
hits = 0
for (question, expected), vector in zip(CHECKS, query_vectors, strict=True):
results = client.search(
collection_name="fin_policy_collection", data=[vector], limit=3,
output_fields=["doc_id", "title", "visibility"],
)
top = client.search(
collection_name="fin_faq_collection", data=[vector], limit=3,
output_fields=["doc_id", "title"],
)
product = client.search(
collection_name="fin_product_collection", data=[vector], limit=3,
output_fields=["doc_id", "title"],
)
merged = [item for group in (top, product, results) for item in group[0]]
merged.sort(key=lambda item: item["distance"], reverse=True)
best = merged[0] if merged else None
# 注意:COSINE 下 pymilvus 返回的 distance 越大越相似
found = ""
if best is not None:
found = f"{best['entity']['doc_id']} (score={best['distance']:.3f}) {best['entity']['title'][:44]}"
mark = "OK " if best is not None and str(best["entity"]["doc_id"]).startswith(expected) else "检查"
hits += 1 if mark == "OK " else 0
print(f" [{mark}] {question}\n → {found}")
print(f"\n自检命中 {hits}/{len(CHECKS)}")
asyncio.run(main())