Files
group_fqcd_jr/tests/unit/service/test_knowledge_granularity.py
T
lzf_0626 06d0f9f231 知识库检索质量修复:「风险评估问卷怎么评分」从必然转人工 → 直接答
## 根因(两个问题叠加)

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,否则两套编号会共存。
2026-09-15 09:31:57 +08:00

140 lines
5.8 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.
"""知识块粒度选择的单元测试。
背景是实测的三次翻车,每条判据都对应其中一次:
1. 客户问「起投多少」和「风险高吗」时命中同一块(整个产品小节),拿到**完全相同**的
整节内容,看起来像客服没听懂问题——所以把表格行拆成了行级子块。
2. 拆细之后「介绍一下」又被某一行抢答(返回"产品期限 90天封闭期")——所以要能换回整节。
3. 两次判据写错:用"含连字符"认子块时,整节块自己的编号 PROD-901 被误判成子块;
用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块、把正确答案挤出了 top1。
第 4 次翻车(2026-09-15)与"同节兄弟子块互相打平"有关:`doc_id` 去重挡不住
`POL-AST-009-07` 与 `POL-AST-009-12` 这种**同父不同子**,实测它们把「风险评估问卷怎么评分」
的 top1/次优差压到 0.002 → 客服判并列转人工。
"""
from app.service.agent.implementations.customer_service import CustomerServiceAgent
from app.service.knowledge_search_service import KnowledgeHit, KnowledgeSearchService
def hit(doc_id: str, score: float, content: str = "正文") -> KnowledgeHit:
return KnowledgeHit(doc_id=doc_id, title=doc_id, content=content, score=score)
def test_parent_of_recognises_row_blocks() -> None:
assert KnowledgeSearchService._parent_of("PROD-007-04") == "PROD-007"
def test_parent_of_rejects_section_blocks() -> None:
"""整节块的编号本身就含连字符(PROD-901),不能被当成子块。"""
assert KnowledgeSearchService._parent_of("PROD-901") is None
assert KnowledgeSearchService._parent_of("FAQ-0016") is None
assert KnowledgeSearchService._parent_of("HNW-003") is None
def test_section_chosen_for_overview_question() -> None:
"""客户问整节时,用父块替掉抢答的那一行。"""
child = {"doc_id": "PROD-007-05", "title": "手册 · 2.1 南方季季盈90天 · 产品期限"}
parent = {"doc_id": "PROD-007", "content": "整节内容"}
assert CustomerServiceAgent._prefer_section(
"南方季季盈90天介绍一下", child, [child, parent]
) is parent
def test_row_kept_when_question_names_the_field() -> None:
"""客户问的正是那一行时不能换成整节,否则"聚焦"就白做了。"""
child = {"doc_id": "PROD-007-04", "title": "手册 · 2.1 南方季季盈90天 · 起投金额"}
parent = {"doc_id": "PROD-007", "content": "整节内容"}
assert CustomerServiceAgent._prefer_section(
"季季盈90天起投多少", child, [child, parent]
) is child
def test_missing_parent_falls_back_to_row() -> None:
"""父块没带回来时仍用子块:宁可答得窄,也不要拿不相干的块搪塞。"""
child = {"doc_id": "PROD-007-05", "title": "手册 · 2.1 南方季季盈90天 · 产品期限"}
assert CustomerServiceAgent._prefer_section("介绍一下", child, [child]) is child
def test_plain_block_is_not_treated_as_section() -> None:
"""FAQ 这类独立块没有子块挂在下面,不该被当成整节块。"""
plain = {"doc_id": "FAQ-0016", "title": "基金赎回到账需要多长时间?"}
assert CustomerServiceAgent._prefer_section("基金赎回几天到账", plain, [plain]) is plain
# --- 同节兄弟子块归并(2026-09-15) -------------------------------------------
def test_sibling_subblocks_of_one_section_collapse_to_the_highest_scoring_one() -> None:
"""同一节的多个子块是"同一答案的不同细节",不是并列候选:只留最高分那条。"""
hits = [
hit("POL-AST-009-12", 0.7359),
hit("POL-AST-009-07", 0.7346),
hit("POL-AST-009-19", 0.7340),
hit("POL-AST-009-51", 0.7340),
]
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
assert [item.doc_id for item in merged] == ["POL-AST-009-12"]
def test_merge_keeps_one_block_per_section() -> None:
"""**不同**父块各自的最高分子块都要留下:它们是真正不同的候选。"""
hits = [
hit("PROD-007-04", 0.86),
hit("PROD-007-05", 0.85),
hit("HNW-005-02", 0.80),
hit("HNW-005-01", 0.79),
]
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
assert [item.doc_id for item in merged] == ["PROD-007-04", "HNW-005-02"]
def test_merge_leaves_plain_blocks_alone() -> None:
"""FAQ / 政策 / 公司信息这类块本身就是细粒度答案:它们之间打平是真的多个候选,
**不能**合并(否则"存在并列"这个信号会被抹掉,客服会硬答一个巧合高分)。"""
hits = [
hit("FAQ-0016", 0.85),
hit("FAQ-0015", 0.84),
hit("POL-SPM-010", 0.83),
hit("POL-AST-009", 0.82),
]
assert KnowledgeSearchService._merge_sibling_subblocks(hits) == hits
def test_merge_preserves_score_order_and_the_parent_block() -> None:
"""归并不改顺序;父块(整节)不受影响,仍会按保底名额回到候选里。"""
hits = [
hit("PROD-007-04", 0.86),
hit("PROD-007", 0.77), # 父块:整节,`_parent_of` 为 None
hit("PROD-007-05", 0.75),
hit("FAQ-0015", 0.60),
]
merged = KnowledgeSearchService._merge_sibling_subblocks(hits)
assert [item.doc_id for item in merged] == ["PROD-007-04", "PROD-007", "FAQ-0015"]
assert [item.score for item in merged] == [0.86, 0.77, 0.60]
def test_merge_is_idempotent() -> None:
"""重复调用不得继续删东西(幂等,便于以后在别处复用)。"""
hits = [hit("PROD-007-04", 0.86), hit("PROD-007-05", 0.75), hit("FAQ-0015", 0.60)]
once = KnowledgeSearchService._merge_sibling_subblocks(hits)
twice = KnowledgeSearchService._merge_sibling_subblocks(once)
assert twice == once
def test_merge_of_empty_list_is_empty() -> None:
assert KnowledgeSearchService._merge_sibling_subblocks([]) == []