一、知识库建设 - 新增 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/ 内含公司内部制度与产品资料, 是否入远程库待确认。
184 lines
8.0 KiB
Python
184 lines
8.0 KiB
Python
"""把 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())
|