## 根因(两个问题叠加) 1. 灌库脚本 tools/build_knowledge_chunks.py 的 expand_table_rows() 用「第一个非分隔行」 当表头且 header 从不重置 → 同一节里第 2 张表格起**表头被当成数据行**。 《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 15 条正文逐字相同的 19 字零信息量碎片;同类共 19 条。 2. 检索层去重只按 doc_id,接不住「同一父块的兄弟子块」(POL-AST-009-07 与 -12 是 不同 doc_id、同一父块)→ 15 条碎片全留在候选里互相打平,把 top1/次优差压到 0.002, 而客服判定要求中置信必须领先 ≥0.07(MIN_GAP)→ 恒判并列转人工。 ## 为什么没有重灌知识库 用 Milvus 现成向量离线复算四种做法(同一问句、同一批向量): 现状 gap 0.002 转人工 只修 bug(=重灌全部收益) gap 0.001 仍转人工,且更糟 只加父块归并 gap 0.093 可答,但答案是 19 字碎片 两处都改 gap 0.076 可答且有内容 原因:第九条被切成 94 块,删掉 15 条表头碎片后,剩下 79 条数据行碎片依然互相打平。 重灌解决不了,却要停机 3–10 分钟并丢掉上传路径的内容(含 R1–R5 那条)。 ## 改了什么 ① app/service/knowledge_search_service.py:新增 _merge_sibling_subblocks() 同一父块的兄弟子块只保留最高分那条;父块自身与 FAQ/政策这类本身就是细粒度答案 的块一律不合并(它们之间打平是真的多个候选)。6 个单测守着。 被丢掉的只是同节其它细节,本节完整内容由 _parent_hits 带回的父块兜底。 ② tools/build_knowledge_chunks.py:修表头识别(markdown 表格只有紧邻 |---| 之前的 那一行才是表头)+ 新增 assert_no_duplicate_contents() 自带守卫 —— 正文完全相同的块必须为 0,否则中止且不写 jsonl。该脚本是一次性灌库脚本、 原来没有单测覆盖,这正是该 bug 活下来的原因,所以守卫放在它自己的执行路径上。 块数 636 → 617;正文完全相同的组 1 组 15 块 → 0 组。 负向验证:换回旧逻辑跑,退出码 1 并报出那 15 份碎片,且未覆盖 jsonl。 ③ tools/drop_table_header_vectors.py:清掉已灌进 Milvus 的 19 条历史碎片。 判定可复现:语义 id(非纯数字)且正文不在修正后产物里。只动语义 id 是因为 纯数字 id 来自上传路径、本来就不在 jsonl 里(实测冒烟 A 线的 top1 就是数字 id 179); 不按长度判是因为短块本身是设计的一部分(「评审标准:管理人资质 15%」13 字是有效答案)。 实删 policy 16 + product 3,faq 0;dry-run 逐条核对过,全部是「标签 + 表头词」形态。 这是唯一一次绕过 Outbox 的删除:事件消费侧要回读 MySQL 行,而这批是无元数据种子向量。 ## 验证 真实链路 10 问句回归 10/10 与预期一致,0 个变坏: 风险评估问卷怎么评分 0.7156 gap 0.1473 → 答(原 0.002 转人工),top1 变成真实答案行 场内基金的管理费率 0.8114 gap 0.0977 → 直接答(冒烟 A 线) 南方季季盈90天起投金额 0.8631 gap 0.3387 → 直接答(行级子块精确命中能力未受影响) 今天天气怎么样 仍正确转人工 端到端:ask_customer_service.py「风险评估问卷怎么评分」→ 直接答,返回完整评分标准; e2e_smoke_test.py 44/44(含 A4 知识库覆盖未转人工); pytest tests/unit tests/contract 1506 passed / 0 failed; 对账 重复正文 15 → 0,孤儿/死向量/缺向量仍全为 0。 (冒烟首跑 42/43 的唯一失败 B6 买入下单 503 是行情过期,补刷后 44/44,与本次无关。) ## 顺带查明 Milvus 的 delete() 是标记删除,约 1 秒后才在 query/search 中不可见(隔离临时集合实测: 删完立即查仍看得到,+1s 起消失)。清理工具第一版"删完立刻复核"因此谎报 19 条残留, 已改为轮询复核并把文案改成实测依据。get_collection_stats().row_count 在删除后仍显示旧值, 这也是对账工具坚持用 query 实际行数的原因。 ## 文档 新增 docs/演示用/知识库检索质量修复-2026-09-15.md(根因、四方案对比、改动、全部证据); 并对 docs/演示用/知识库向量对账与清理-2026-09-15.md 做更正 —— 451 条短块里 429 条是 设计内的有效数据行、只有 19 条是 bug 产物;"按长度合并短块 + 重建重灌"的建议已被实测否决。 ## 仍未解决(记录在案) 「高净值客户有什么权益」gap 0.0075 仍转人工:根因是不同父块之间同族内容打平 (金卡 vs 白金权益),父块归并救不了也不该救,要从内容侧或业务口径入手。 knowledge/_chunks.jsonl(新 617 块、编号连续)与 Milvus(旧编号、含 19 个空洞)目前不一致; 不重灌无影响,但下次重灌必须 drop 集合重建而不是 upsert,否则两套编号会共存。
13 KiB
知识库检索质量修复:为什么「风险评估问卷怎么评分」答不出来(2026-09-15)
承接
docs/演示用/知识库向量对账与清理-2026-09-15.md(那份是对账 + 三项收口)。 本文是同一问题链的第二段:对账查出的"碎片"到底该怎么修。所有数字都是本机真机实测。
摘要(先看这五句)
- 目标问题已修好:「风险评估问卷怎么评分」从"必然转人工"变成"答(中置信 + 领先足够)",
top1/次优差从 0.002 → 0.147,top1 从一条 19 字零信息量碎片变成真实答案行
(
第九条 问卷内容及评分标准:A. 80%以上 2分),客户还能拿到整节说明。 - 根因是两个问题叠加,一个是灌库脚本的真 bug、一个是检索层的判据缺口。
- "重灌知识库"不是答案:实测只修 bug 再重灌,gap 会从 0.002 掉到 0.001(更糟)。
- 两处小改 + 清 19 条历史碎片就够了,没有重灌、没有停机。
- 10 个问句真实链路回归 10/10 与预期一致;冒烟 44/44 通过;单测 1506 passed / 0 failed。
1. 根因
1.1 灌库脚本的真 bug:表头被当成数据行
tools/build_knowledge_chunks.py 的 expand_table_rows() 用「第一个非分隔行」当表格表头
(if not header: header = cells),而 header 从不重置。于是同一节里第 2 张表格起,
表头行被当成数据行:
### 第九条 问卷内容及评分标准 ← 一个 chunk,里面有 16 张问卷表格
**Q1:您的年龄段是?**(满分10分)
| 选项 | 分值 | ← Q1 表头:被正确识别为 header
|------|------|
| A. 18-25周岁 | 8分 | ← 数据行 ✓
**Q2:您的最高学历是?**(满分5分)
| 选项 | 分值 | ← Q2 表头:header 已非空 → **被当成数据行** ❌
|------|------| → 产出「第九条 问卷内容及评分标准:选项 分值」
结果:15 条正文逐字相同的 19 字碎片(Q2–Q16 各一条)。同类还有
评级方法:指标 权重、南方科技创新股票:行业 占比、南方精选QDII:地区 占比、
南方传世增额终身寿险:保单年度 现金价值 —— 共 19 条,全部零信息量。
1.2 检索层的判据缺口:接不住"同一父块的兄弟子块"
knowledge_search_service 的去重只按 doc_id,而 POL-AST-009-07 与 POL-AST-009-12
是两个不同的 doc_id、同一个父块,于是 15 条碎片全部留在候选里互相打平,
把 top1/次优差压到 0.002 —— 客服按"中置信必须领先次优 ≥0.07"(MIN_GAP)判并列 → 转人工。
值得一提的是:这段代码已经想到了问题的一半——它专门把"整节父块"排到候选末尾、 不让它参与 top1/top2 判定(注释里写着实测"挤到第 2 位时 gap 会从 0.090 掉到 0.076")。 漏的是另一半:兄弟子块。
2. 为什么"重灌知识库"不是答案(四方案实测对比)
用 Milvus 里现成的真实向量离线复算(只对问句做 1 次 embedding,不写任何数据):
| 方案 | top1 | 次优 | gap | 结果 |
|---|---|---|---|---|
| 现状 | 0.7359(19 字碎片) | 0.7346(同父碎片) | 0.002 | ❌ 转人工 |
| 只修 bug(= 重灌能拿到的全部收益) | 0.7172 | 0.7165(同父) | 0.001 | ❌ 更糟,仍转人工 |
| 只加父块归并 | 0.7359(19 字垃圾) | 0.6417 | 0.093 | ⚠️ 可答,但答出"选项 分值" |
| 两处都改 | 0.7172(真实答案行) | 0.6417(整节) | 0.076 | ✅ 可答且有内容 |
原因:第九条被切成 94 块(16 张表 × 约 6 行),删掉 15 条表头碎片后,
剩下 79 条数据行碎片(A. 80%以上 2分)依然彼此打平。所以:
- 重灌解决不了这个问题,却要付出停机 3–10 分钟 + 丢掉上传路径内容的代价;
- 归并解决了打平,但不修 bug 就会把"选项 分值"当答案吐给客户;
- 两者是互补的,缺一不可。
3. 改了什么(三处)
① 检索层:同一父块只保留最高分子块
app/service/knowledge_search_service.py 新增 _merge_sibling_subblocks(),
插在 doc_id 去重之后。判据:有父块的行级子块(_parent_of 非空,即形如 PROD-007-04)
按父块归并;父块自身、以及 FAQ/政策/公司信息这类本身就是细粒度答案的块
(_parent_of 为 None)一律不合并 —— 它们之间打平是真的存在多个候选,
该转人工就得转人工。
为什么不会丢信息:被丢掉的兄弟子块不是答案本身,只是同一节的其它细节;
本节完整内容由 _parent_hits 带回来的**父块(整节)**兜底,它按保底名额占最后一席。
所以客户拿到的仍是"最相关的那一行 + 整节说明"。
6 个单测守着(tests/unit/service/test_knowledge_granularity.py):只留最高分、不同父块各自保留、
普通块不动、保持分数顺序、幂等、空列表。
② 灌库脚本:修掉表头 bug + 自带守卫
tools/build_knowledge_chunks.py:
expand_table_rows()改为按分隔行|---|认表头(markdown 表格的定义:只有紧邻分隔行 之前的那一行才是表头),用prev延迟一行判断;- 新增
assert_no_duplicate_contents():正文完全相同的块必须为 0,否则中止、不写 jsonl。
为什么加守卫而不是单测:这个脚本是"一次性灌库脚本"、没有单测覆盖 —— 这正是该 bug 能活下来的
原因。守卫放在脚本自己的执行路径上,每次重灌都会跑一遍。
(负向验证:把旧逻辑换回去跑,脚本退出码 1、明确报出"15 份 第九条…:选项 分值"、
且没有覆盖 _chunks.jsonl。)
块数 636 → 617;正文完全相同的组 1 组 15 块 → 0 组。
③ 清掉已经灌进 Milvus 的那 19 条历史碎片
tools/drop_table_header_vectors.py(默认 dry-run)。判定规则可复现:
待删 = 语义 id(非纯数字)的向量,且正文不在修正后产物的正文集合里(新
_chunks.jsonl)。
两条限定都是有意的:
- 只动语义 id:纯数字 id 来自上传路径(
POST /api/v1/knowledge/upload), 本来就不在 jsonl 里,是合法内容(实测「场内基金的管理费率是多少」的 top1 就是数字 id 179); - 不按长度判:短块本身是设计的一部分(
评审标准:管理人资质 15%只有 13 字,却是有效答案)。
实测删除 19 条:fin_policy_collection 16 条 + fin_product_collection 3 条,faq 0 条。
删除前的 dry-run 逐条列出,形态全部是"标签 + 表头词"(指标 权重、行业 占比、保单年度 现金价值),
没有误伤任何有信息量的内容。证据(全部 doc_id + 正文,可据此重建)落在
docs/evidence/table-header-vectors-dropped.json。
⚠️ 这是唯一一次绕过 Outbox 的删除:事件消费侧要按
knowledge_id回读 MySQL 行 (knowledge_vector_worker._load()),而这批是无元数据种子向量(MySQL 里一行都没有), 事件只会一路 failed。理由与边界都写在工具 docstring 里。
4. 验证证据
4.1 真实链路 10 问句回归(KnowledgeSearchService.search)
✅ 场内基金的管理费率是多少 top1=0.8114 gap=0.0977 → 直接答(高置信) [冒烟 A 线]
✅ 基金申购后多久能确认 top1=0.8468 gap=0.2136 → 直接答(高置信) [用户报的]
✅ r1到r5分别代表什么 top1=0.7387 gap=0.0766 → 答(中置信+领先足够)
✅ 风险评估问卷怎么评分 top1=0.7156 gap=0.1473 → 答(中置信+领先足够) ← 本次修复目标
✅ 南方季季盈90天的起投金额是多少 top1=0.8631 gap=0.3387 → 直接答(高置信)
✅ 基金赎回到账需要多长时间 top1=0.8480 gap=0.1647 → 直接答(高置信)
✅ 你们公司叫什么名字 top1=0.5860 gap=0.1040 → 答(中置信+领先足够)
✅ 管理费怎么收 top1=0.6338 gap=0.0930 → 答(中置信+领先足够)
✅ 高净值客户有什么权益 top1=0.7214 gap=0.0075 → 转人工(预期如此,见 §6)
✅ 今天天气怎么样 top1=0.4157 gap=0.0096 → 转人工(预期如此)
与预期不符:0 个
关键回归:「南方季季盈90天的起投金额是多少」top1 仍是行级子块 PROD-007-04,
精确命中能力一点没丢,gap 反而从 0.113 升到 0.339 —— 归并只吃掉了"同一节的其它细节"。
4.2 端到端与验收
python tools/ask_customer_service.py "风险评估问卷怎么评分"→ 直接答,返回完整评分标准表格;python tools/e2e_smoke_test.py→ 44/44 通过,其中A4 知识库覆盖(未转人工)transfer_required=False✅;pytest tests/unit tests/contract→ 1506 passed, 2 skipped, 0 failed;python tools/reconcile_knowledge_vectors.py→ 重复正文 15 → 0, policy 向量 297 → 281、product 238 → 235,孤儿/死向量/缺向量仍全为 0。
补记:冒烟首次跑是 42/43,唯一失败
B6 买入下单 HTTP=503是行情过期 (MAX_QUOTE_AGE15 分钟,演示最容易翻的一环)——python tools/sync_market_prices.py补刷后重跑即 44/44,与本次改动无关。
5. 顺带查清的一个 Milvus 语义(我的工具因此错过一次)
清理工具第一版在删完 19 条后谎报"仍有 19 条残留"。用隔离临时集合做实验查明:
client.delete(ids=["B"]) 之后(同一进程):
立即 空 filter 全量=['A','B','C'] 精确 filter B=['B'] ← 还看得到!
+1s 空 filter 全量=['A','C'] 精确 filter B=[] ← 生效
⇒ Milvus 的 delete() 是标记删除,约 1 秒后才在 query/search 中不可见,
不是"同进程"问题(新起进程立即查也已经是干净的)。所以"删完立刻复核"必然谎报。
工具已改为轮询复核(1.5s × 最多 4 次),文案也改成实测依据而不是猜的
(第一版写的"稍等或重启进程"是错的)。
另注:get_collection_stats().row_count 在删除后仍显示旧值(policy 仍报 297),
因为它把已软删、未 compaction 的行也算进去 —— 这也是对账工具坚持用 query 实际行数的原因。
6. 对上一份文档的两处更正(重要)
| 原说法 | 更正 |
|---|---|
| §4「451 条向量是灌库切分缺陷产生的碎片,占比 68%」 | 数字对,定性不准:451 条里 429 条是设计内的行级子块(真实数据行,如「评审标准:管理人资质 15%」,是有效答案);只有 19 条是 bug 产物。行级子块的设计是对的(它解决"具体小问题命中整节、答非所问",有实测依据)。 |
§4「补内容救不了,也不该靠放宽 MIN_GAP 解决」+ §6「修切分属于要重灌的动作」 |
方向对(确实不该改阈值),但当时没找到真正的解法。真正的解法是两处小改、无需重灌(本文件 §3),且"只重灌"会让 gap 更小。 |
7. 仍未解决的(记录在案)
- 「高净值客户有什么权益」gap 0.0075 仍转人工 —— 根因不同:它是不同父块之间
同族内容打平(
HNW-004金卡权益 vsHNW-005白金权益,语义天然接近), 父块归并救不了也不该救(客户问的确实横跨多个层级)。 要修得从内容侧(把"各层级权益"合并成一块对比表)或从业务口径(问层级时先反问)入手,待定。 knowledge/_chunks.jsonl与 Milvus 的 doc_id 目前不一致:jsonl 已是修正后的 617 块 (编号连续),而 Milvus 里仍是旧编号(含被删掉的 19 个空洞)。 不重灌时没有任何影响(检索不依赖编号连续、父块编号未变)。但下次要重灌时: 必须 drop 集合重建(而不是 upsert),否则新旧两套编号会共存、同一内容出现两份向量。- 38 行上传链路测试垃圾(
e2e-*.md等)仍可用POST /api/v1/knowledge/{id}/vector-cleanups清理,属可选卫生工作。
8. 复现命令
# 1) 重新生成知识块(脚本自带守卫:有重复正文就直接失败退出)
python tools/build_knowledge_chunks.py
# 2) 清历史表头碎片(默认 dry-run,先看再删)
python tools/drop_table_header_vectors.py
python tools/drop_table_header_vectors.py --apply
# 3) 对账与问句回归
python tools/reconcile_knowledge_vectors.py
python tools/ask_customer_service.py "风险评估问卷怎么评分"
python tools/e2e_smoke_test.py # 前置:行情别过期,必要时 python tools/sync_market_prices.py
# 4) 单测
python -m pytest tests/unit/service/test_knowledge_granularity.py -q
⚠️ 改了检索层就要重启 API(uvicorn 没开 --reload):本机双击
启动金融Agent平台.bat,或 python -m uvicorn app.main:app --port 8000。
Worker 也要在跑(python -m app.worker),否则客服 run 永远停在 queued。