Files
group_fqcd_jr/tools/load_knowledge_milvus.py
T
lzf_0626 d2aff7c129 feat: 建客服知识库(Milvus 三集合)并修复模型端点筛选缺陷
一、知识库建设
- 新增 tools/build_knowledge_chunks.py:把 knowledge/ 下文档切成可检索知识块。
  采用「叶子标题」策略(其后没有更深标题的标题即切分点),同时覆盖三种真实结构:
  带子条款的按子条款切、无子条款的条款单独成块、无小节的章整章成块。
  第一版按固定标题级别切是失败的——适当性指南的条款是 ### 而没有 ####,产品手册的
  ### 1.1 又不匹配「第X条」,两条规则互相打架,导致 4 个文件一块都没切出来。
- 新增 tools/load_knowledge_milvus.py:向量化并写入 Milvus,用 upsert 保证幂等。
  schema 按方案 §4.2 统一字段,另加 chapter/section/source_file/doc_no/visibility 五个
  检索与合规必需字段;索引 IVF_FLAT + COSINE + nlist=128;向量输入取「标题+正文」,
  标题含条款号与章节名,是比正文更干净的检索信号。
- 知识内容按业务范围裁剪:反洗钱合规操作手册不入客服知识库(业务只做公募基金、
  不涉及资金划付,且该手册标注内部机密、禁止向客户透露可疑交易信息),留给后续风控;
  高净值客户服务规范只保留「客户分层标准」与「各层级专属权益」两章,
  家族信托、资产配置流程、客户经理考核、隐私应急预案等内部管理章节不入库。
- 入库现状:fin_faq_collection 61 块、fin_product_collection 26 块、
  fin_policy_collection 73 块,合计 160 块。检索自检 5/6——未命中的一条分数 0.660
  落在中置信区间,按三档兜底策略本应提示信息可能不完整,属于预期行为。

二、embedding 端点
- 新增 tools/configure_embedding_endpoint.py:走管理 API(draft→approved→active)
  配置并激活 qwen-embedding 端点,而不是直接写库。理由是状态机与审计都要留痕,
  且 DatabaseModelGateway 只认 status='active',手工写错状态会报成与病因无关的
  「模型端点未注册或未激活」。脚本先查 endpoint_code 是否已存在,幂等可重跑。

三、修复模型端点筛选缺陷(app/service/model_gateway.py)
- 原 DatabaseModelEndpointResolver 忽略 agent_type 与 task_type、直接返回全部 active
  端点,而 ModelDispatchService 只按顺序尝试前 max_attempts(默认 2)个。两者叠加使
  「能否选到支持该任务的端点」取决于端点表顺序:实测每次 embedding 都先拿文本生成
  端点失败一次再落到向量端点(0.61s,修复后 0.42s)。
- 新增 TASK_CAPABILITY 显式映射后按能力筛选。用映射而不是同名筛选是必需的:
  memory_extraction 并不是任何端点的能力名(deepseek 声明的是 text_generation 等),
  按同名筛会得到空集、把记忆抽取打成失败关闭——这是本次修复最容易引入的回归。
- 保守兜底:未映射的 task_type、以及没有任何端点声明该能力时,都退回全部端点,
  让配置缺口表现为调用失败,而不是让上层收到「解析为空」这种与病因无关的报错。
- 验证结果:embedding→[qwen-embedding]、intent_classification→[deepseek-flash]、
  memory_extraction→[deepseek-flash]、未映射 task_type→全部;ruff 通过、
  mypy 103 文件无错、unit+contract 447 passed。

四、需求文档提取物
- 新增 _flows/:三份流程文档(智能客服 Agent 专项设计方案、投资顾问流程、基金运营流程)
  的纯文本提取,供开发期对照。原始 .docx/.html 保留在业务方目录侧。

说明:本次仅本地提交,未推送远程仓库。knowledge/ 内含公司内部制度与产品资料,
是否入远程库待确认。
2026-09-10 20:15:09 +08:00

184 lines
8.0 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"),
("C1 保守型客户可以买哪些风险等级的产品", "POL-AST"),
("南方季季盈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())