知识库检索质量修复:「风险评估问卷怎么评分」从必然转人工 → 直接答

## 根因(两个问题叠加)

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,否则两套编号会共存。
This commit is contained in:
2026-09-15 09:31:57 +08:00
parent 859c89898e
commit 06d0f9f231
9 changed files with 867 additions and 196 deletions
+51 -69
View File
@@ -212,17 +212,17 @@
}
},
"fin_policy_collection": {
"milvus_vectors": 297,
"milvus_vectors": 281,
"mysql_rows": 0,
"mysql_active": 0,
"active_files": 0,
"orphan_vectors": 0,
"stale_vectors": 0,
"missing_vectors": 0,
"unbacked_seed_vectors": 297,
"duplicate_content_groups": 1,
"duplicate_content_vectors": 15,
"tiny_vectors": 208,
"unbacked_seed_vectors": 281,
"duplicate_content_groups": 0,
"duplicate_content_vectors": 0,
"tiny_vectors": 192,
"heading_only_vectors": 0,
"duplicated_active_files": 0,
"detail": {
@@ -262,19 +262,16 @@
"POL-AST-009-04",
"POL-AST-009-05",
"POL-AST-009-06",
"POL-AST-009-07",
"POL-AST-009-08",
"POL-AST-009-09",
"POL-AST-009-10",
"POL-AST-009-11",
"POL-AST-009-12",
"POL-AST-009-13",
"POL-AST-009-14",
"POL-AST-009-15",
"POL-AST-009-16",
"POL-AST-009-17",
"POL-AST-009-18",
"POL-AST-009-19",
"POL-AST-009-20",
"POL-AST-009-21",
"POL-AST-009-22",
@@ -282,69 +279,57 @@
"POL-AST-009-24",
"POL-AST-009-25",
"POL-AST-009-26",
"POL-AST-009-27",
"POL-AST-009-28",
"POL-AST-009-29",
"POL-AST-009-30",
"POL-AST-009-31",
"POL-AST-009-32",
"POL-AST-009-33",
"POL-AST-009-34",
"POL-AST-009-35",
"POL-AST-009-36",
"POL-AST-009-37",
"POL-AST-009-38",
"POL-AST-009-39",
"POL-AST-009-40",
"POL-AST-009-41",
"POL-AST-009-42",
"POL-AST-009-43",
"POL-AST-009-44",
"POL-AST-009-45",
"POL-AST-009-46",
"POL-AST-009-47",
"POL-AST-009-48",
"POL-AST-009-49",
"POL-AST-009-50",
"POL-AST-009-51",
"POL-AST-009-52",
"POL-AST-009-53",
"POL-AST-009-54",
"POL-AST-009-55",
"POL-AST-009-56",
"POL-AST-009-57",
"POL-AST-009-58",
"POL-AST-009-59",
"POL-AST-009-60",
"POL-AST-009-61",
"POL-AST-009-62",
"POL-AST-009-63",
"POL-AST-009-64",
"POL-AST-009-65",
"POL-AST-009-66",
"POL-AST-009-67",
"POL-AST-009-68",
"POL-AST-009-69",
"POL-AST-009-70",
"POL-AST-009-71",
"POL-AST-009-72",
"POL-AST-009-73",
"POL-AST-009-74",
"POL-AST-009-75",
"POL-AST-009-76",
"POL-AST-009-77",
"POL-AST-009-78",
"POL-AST-009-79",
"POL-AST-009-80",
"POL-AST-009-81",
"POL-AST-009-82",
"POL-AST-009-83",
"POL-AST-009-84",
"POL-AST-009-85",
"POL-AST-009-86",
"POL-AST-009-87",
"POL-AST-009-88",
"POL-AST-009-89",
"POL-AST-009-90",
"POL-AST-009-91",
"POL-AST-009-92",
@@ -428,8 +413,23 @@
"POL-SPM-012-02",
"POL-SPM-012-03",
"POL-SPM-012-04",
"POL-SPM-012-05",
"POL-SPM-012-06"
"POL-SPM-012-06",
"POL-SPM-012-07",
"POL-SPM-012-08",
"POL-SPM-013",
"POL-SPM-014",
"POL-SPM-014-01",
"POL-SPM-014-02",
"POL-SPM-014-03",
"POL-SPM-014-04",
"POL-SPM-014-05",
"POL-SPM-014-06",
"POL-SPM-015",
"POL-SPM-016",
"POL-SPM-017",
"POL-SPM-018",
"POL-SPM-019",
"POL-SPM-020"
],
"tiny_vectors": [
"POL-AST-005-02",
@@ -451,19 +451,16 @@
"POL-AST-009-04",
"POL-AST-009-05",
"POL-AST-009-06",
"POL-AST-009-07",
"POL-AST-009-08",
"POL-AST-009-09",
"POL-AST-009-10",
"POL-AST-009-11",
"POL-AST-009-12",
"POL-AST-009-13",
"POL-AST-009-14",
"POL-AST-009-15",
"POL-AST-009-16",
"POL-AST-009-17",
"POL-AST-009-18",
"POL-AST-009-19",
"POL-AST-009-20",
"POL-AST-009-21",
"POL-AST-009-22",
@@ -471,103 +468,88 @@
"POL-AST-009-24",
"POL-AST-009-25",
"POL-AST-009-26",
"POL-AST-009-27",
"POL-AST-009-28",
"POL-AST-009-29",
"POL-AST-009-30",
"POL-AST-009-32",
"POL-AST-009-33",
"POL-AST-009-34",
"POL-AST-009-35",
"POL-AST-009-36",
"POL-AST-009-37",
"POL-AST-009-38",
"POL-AST-009-39",
"POL-AST-009-40",
"POL-AST-009-41",
"POL-AST-009-42",
"POL-AST-009-43",
"POL-AST-009-44",
"POL-AST-009-45",
"POL-AST-009-46",
"POL-AST-009-47",
"POL-AST-009-48",
"POL-AST-009-49",
"POL-AST-009-50",
"POL-AST-009-51",
"POL-AST-009-52",
"POL-AST-009-53",
"POL-AST-009-54",
"POL-AST-009-55",
"POL-AST-009-57",
"POL-AST-009-58",
"POL-AST-009-59",
"POL-AST-009-60",
"POL-AST-009-61",
"POL-AST-009-63",
"POL-AST-009-64",
"POL-AST-009-65",
"POL-AST-009-66",
"POL-AST-009-67",
"POL-AST-009-68",
"POL-AST-009-69",
"POL-AST-009-70",
"POL-AST-009-71",
"POL-AST-009-72",
"POL-AST-009-73",
"POL-AST-009-74",
"POL-AST-009-75",
"POL-AST-009-76",
"POL-AST-009-77",
"POL-AST-009-78",
"POL-AST-009-79",
"POL-AST-009-80",
"POL-AST-009-81",
"POL-AST-009-82",
"POL-AST-009-83",
"POL-AST-009-84",
"POL-AST-009-86",
"POL-AST-009-87",
"POL-AST-009-88",
"POL-AST-009-89",
"POL-AST-009-90",
"POL-AST-010-01"
"POL-AST-010-01",
"POL-AST-010-02",
"POL-AST-010-03",
"POL-AST-010-04",
"POL-AST-010-05",
"POL-AST-011-01",
"POL-AST-011-02",
"POL-AST-011-03",
"POL-AST-011-04",
"POL-AST-011-05",
"POL-AST-012-01",
"POL-AST-012-02",
"POL-AST-012-03",
"POL-AST-012-04",
"POL-AST-012-05",
"POL-AST-021-01"
],
"heading_only_vectors": [],
"duplicated_active_files": {},
"duplicate_content_groups": {
"534022938b4b": [
"POL-AST-009-07",
"POL-AST-009-12",
"POL-AST-009-19",
"POL-AST-009-27",
"POL-AST-009-32",
"POL-AST-009-39",
"POL-AST-009-45",
"POL-AST-009-51",
"POL-AST-009-57",
"POL-AST-009-63",
"POL-AST-009-69",
"POL-AST-009-74",
"POL-AST-009-79",
"POL-AST-009-84",
"POL-AST-009-89"
]
}
"duplicate_content_groups": {}
}
},
"fin_product_collection": {
"milvus_vectors": 238,
"mysql_rows": 161,
"milvus_vectors": 235,
"mysql_rows": 164,
"mysql_active": 24,
"active_files": 1,
"orphan_vectors": 0,
"stale_vectors": 0,
"missing_vectors": 0,
"unbacked_seed_vectors": 214,
"unbacked_seed_vectors": 211,
"duplicate_content_groups": 0,
"duplicate_content_vectors": 0,
"tiny_vectors": 184,
"tiny_vectors": 181,
"heading_only_vectors": 3,
"duplicated_active_files": 0,
"detail": {
@@ -653,7 +635,6 @@
"PROD-004-13",
"PROD-004-14",
"PROD-004-15",
"PROD-004-16",
"PROD-004-17",
"PROD-004-18",
"PROD-004-19",
@@ -675,7 +656,6 @@
"PROD-005-11",
"PROD-005-12",
"PROD-005-13",
"PROD-005-14",
"PROD-005-15",
"PROD-005-16",
"PROD-005-17",
@@ -756,7 +736,6 @@
"PROD-011-08",
"PROD-011-09",
"PROD-011-10",
"PROD-011-11",
"PROD-011-12",
"PROD-011-13",
"PROD-011-14",
@@ -774,7 +753,10 @@
"PROD-012-10",
"PROD-012-11",
"PROD-013",
"PROD-014"
"PROD-014",
"PROD-015",
"PROD-016",
"PROD-017"
],
"tiny_vectors": [
"177",
@@ -843,7 +825,6 @@
"PROD-004-12",
"PROD-004-13",
"PROD-004-14",
"PROD-004-16",
"PROD-004-17",
"PROD-004-18",
"PROD-004-19",
@@ -863,7 +844,6 @@
"PROD-005-10",
"PROD-005-11",
"PROD-005-12",
"PROD-005-14",
"PROD-005-15",
"PROD-005-16",
"PROD-005-17",
@@ -876,7 +856,9 @@
"PROD-006-05",
"PROD-006-06",
"PROD-006-07",
"PROD-006-08"
"PROD-006-08",
"PROD-006-09",
"PROD-006-10"
],
"heading_only_vectors": [
"177",
@@ -889,7 +871,7 @@
}
},
"errors": {},
"mysql_rows_total": 203,
"mysql_rows_total": 206,
"collections_checked": [
"fin_faq_collection",
"fin_policy_collection",