feat(knowledge): 产品知识拆到表格行级,并按问句选粒度

问题(客户实测反馈):同一会话里问「季季盈90天起投多少」和「那它风险高吗」,两次回答
**一模一样**——都是整个产品小节的表格。客户问的是风险,收到的是整张说明书,看起来像
客服没听懂问题。

根因是切分粒度:原来"一个叶子标题 = 一块",产品手册里就是整个产品小节(表格 + 说明)
成一块。这既让两个不同的问题命中同一块,也让整节几百字的向量成了"整节的混合语义",
与"起投多少"这种具体小问题相似度天然偏低(实测该问句向量 top1 仅 0.6291,够不到 0.75
硬门槛,只能靠与次优的差值勉强通过)。

改动三处:
1. 切分:Markdown 表格的每一行额外生成一个**自解释**的小块("南方季季盈90天:起投金额
   1万元"),挂在父块 doc_id 下(PROD-007-04),父块照旧保留。知识块 160 → 631。
   效果:该问句的命中分从 0.6291 升到 0.869,命中的正是"起投金额"那一行。
2. 检索:命中行级子块时把它的整节父块一并带回(分数按 0.9 折算),供调用方按问句选粒度。
   整节块保底占最后一个名额,且不参与 top1/top2 判定——实测它挤到第 2 位会把 gap 从
   0.090 压到 0.076,几乎跌破 0.07 的转人工门槛。
3. 客服:命中的是行级子块时,看问句与子块标签是否真的对得上——「起投多少」对「起投金额」
   对得上,用那一行;「介绍一下」对不上,换成整节。

过程中两次判据写错并已修正(都固化进了测试):用"含连字符"认子块时,整节块自己的编号
PROD-901 被误判成子块;用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块排到后面,
把正确答案挤出 top1、害得「基金赎回几天到账」转人工。

验证:起投/管理费等字段问法给出聚焦的单行答案;"介绍一下"给出整节;FAQ 与政策问法不受
影响(换话题、指代追问等此前修好的场景复测通过);
ruff / mypy(113 文件) / 468 unit+contract / 29 integration 全绿。
This commit is contained in:
2026-09-10 22:29:23 +08:00
parent a6c09fa3c4
commit 4c2b147793
5 changed files with 769 additions and 13 deletions
@@ -0,0 +1,58 @@
"""知识块粒度选择的单元测试。
背景是实测的三次翻车,每条判据都对应其中一次:
1. 客户问「起投多少」和「风险高吗」时命中同一块(整个产品小节),拿到**完全相同**的
整节内容,看起来像客服没听懂问题——所以把表格行拆成了行级子块。
2. 拆细之后「介绍一下」又被某一行抢答(返回"产品期限 90天封闭期")——所以要能换回整节。
3. 两次判据写错:用"含连字符"认子块时,整节块自己的编号 PROD-901 被误判成子块;
用"不含两位数字后缀"认整节块时,FAQ 块全被误判成整节块、把正确答案挤出了 top1。
"""
from app.service.agent.implementations.customer_service import CustomerServiceAgent
from app.service.knowledge_search_service import KnowledgeSearchService
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