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
+65 -1
View File
@@ -132,6 +132,50 @@ def chunk_qa(text: str) -> list[dict[str, str]]:
return chunks
TABLE_ROW = re.compile(r"^\|(.+)\|\s*$")
LEADING_NUMBER = re.compile(r"^\d+(?:\.\d+)*\s*")
def expand_table_rows(chunk: dict[str, str], parent_id: str) -> list[dict[str, object]]:
"""把 Markdown 表格的每一行拆成自解释的小块(父块照旧保留)。
为什么需要:现在的粒度是"一个叶子标题 = 一块",产品手册里就是**整个产品小节**
(表格 + 说明)成一块。于是客户问「起投多少」和问「风险高吗」命中同一块、拿到
**完全相同**的整节内容——客户会觉得客服没听懂问题,只是把说明书重贴一遍。
顺带地,整节几百字的向量是"整节的混合语义",与"起投多少"这种具体小问题相似度
天然偏低(实测该问句向量 top1 只有 0.6291,够不到 0.75 门槛)。
小块必须**自解释**:只回「1万元」客户不知道说的是哪个产品,所以带上产品名与行标签。
父块保留,客户问「这个产品怎么样」时仍要能拿到完整一节。
"""
blocks: list[dict[str, object]] = []
header: list[str] = []
name = LEADING_NUMBER.sub("", chunk["section"]).strip() or chunk["section"]
for line in chunk["content"].splitlines():
match = TABLE_ROW.match(line.strip())
if not match:
continue
cells = [cell.strip() for cell in match.group(1).split("|")]
if all(set(cell) <= {"-", ":", " "} for cell in cells):
continue # 分隔行 |---|
if not header:
header = cells
continue
if len(cells) < 2:
continue
label, value = cells[0], cells[1]
if not label or not value:
continue
blocks.append({
"title": f"{chunk['title']} · {label}",
"chapter": chunk["chapter"],
"section": f"{name} · {label}",
"content": f"{name}:{label} {value}",
"parent_id": parent_id,
})
return blocks
records: list[dict[str, object]] = []
for relative, config in SOURCES.items():
text = (Path("knowledge") / relative).read_text(encoding="utf-8")
@@ -144,8 +188,9 @@ for relative, config in SOURCES.items():
if any(chunk["chapter"].startswith(prefix) for prefix in allowed)
]
for order, chunk in enumerate(chunks, 1):
parent_id = f"{config['prefix']}-{order:03d}"
records.append({
"doc_id": f"{config['prefix']}-{order:03d}",
"doc_id": parent_id,
"collection": config["collection"],
"title": chunk["title"],
"content": chunk["content"],
@@ -160,6 +205,25 @@ for relative, config in SOURCES.items():
"visibility": config["visibility"],
"chars": len(chunk["content"]),
})
# 行级子块:挂在父块 doc_id 下(PROD-007-01 这种),父块编号不受新增子块影响,
# 因此反复重跑本脚本得到的 doc_id 是稳定的。
for row_order, block in enumerate(expand_table_rows(chunk, parent_id), 1):
records.append({
"doc_id": f"{parent_id}-{row_order:02d}",
"collection": config["collection"],
"title": block["title"],
"content": block["content"],
"chapter": block["chapter"],
"section": block["section"],
"tags": config["tags"],
"doc_no": config["doc_no"],
"version": config["version"],
"effective_date": config["effective_date"],
"expire_date": "", "source_url": "", "reviewer": "",
"source_file": relative,
"visibility": config["visibility"],
"chars": len(str(block["content"])),
})
for order, chunk in enumerate(
chunk_qa((Path("knowledge") / "faq/高频问答对.txt").read_text(encoding="utf-8")), 1