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

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

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
+197
View File
@@ -0,0 +1,197 @@
"""删除「表格表头被误当数据行」产生的零信息量知识向量(一次性清理;**默认 dry-run**)。
python tools/drop_table_header_vectors.py # 只看要删什么
python tools/drop_table_header_vectors.py --apply # 真删(并导出证据)
## 这是什么
`tools/build_knowledge_chunks.py` 的 `expand_table_rows()` 曾有一个 bug:它用「第一个非分隔行」
当表格表头,且 `header` 从不重置 → **同一节里第 2 张表格起,表头行被当成数据行**,
产出形如「第九条 问卷内容及评分标准:选项 分值」的 19 字零信息量块。
《个人投资者适当性管理指南》第九条下有 16 张问卷表格 → 15 条**正文逐字相同**的碎片。
后果不是"多占点存储",而是**检索质量**:这些碎片彼此分数贴得极近(实测 top1/次优差
**0.002**),而客服的判定是"中置信必须领先次优 ≥0.07"(`customer_service.py` MIN_GAP),
于是「风险评估问卷怎么评分」这类问题被**永远判成并列 → 转人工**,
真正有内容的块被挤到第 5 名。
脚本侧的 bug 已修(`expand_table_rows` + 自带守卫 `assert_no_duplicate_contents`),
本工具负责**清掉已经灌进 Milvus 的那批历史碎片**。
## 判定规则(为什么这样判,而不是"删所有短块")
待删 = **语义 id**(非纯数字)的向量,且它的正文**不在**修正后产物的正文集合里
(`knowledge/_chunks.jsonl`,由修好的脚本生成)。
两条限定都是有意的:
1. **只动语义 id**:纯数字 id 是**上传路径**产生的(`POST /api/v1/knowledge/upload`),
它们本来就不在 jsonl 里,是**合法内容**(实测「场内基金的管理费率是多少」的 top1 就是
数字 id 179)——"不在 jsonl 里"对它们不构成删除理由。
2. **不按长度判**:短块本身是设计的一部分(「评审标准:管理人资质 15%」13 字,是有效答案)。
缺陷特征是"**正文在正确产物里不存在**",不是"短"。
## 为什么不走 `knowledge.vector_delete_requested` 事件
事件消费侧(`knowledge_vector_worker.remove`)要按 `knowledge_id` **回读 MySQL 行**
(`_load()`),而这批是**无元数据种子向量**(MySQL 里一行都没有)→ 事件会一路
`RecoverableAgentError` 变成 failed/dead。没有可挂事件的载体,只能直连向量库删。
**这是本条路径唯一一次绕过 Outbox**,所以本工具:默认只读、删除前导出证据、
且删除范围由上面那条可复现的规则限定(不是"看着像垃圾就删")。
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.core.config import get_settings # noqa: E402
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS # noqa: E402
from app.core.knowledge_schema import detect_schema # noqa: E402
CHUNKS = Path("knowledge") / "_chunks.jsonl"
DEFAULT_OUT = Path("docs/evidence/table-header-vectors-dropped.json")
#: 复核前的等待与轮询次数。**实测依据**(隔离临时集合做的实验):`client.delete()` 是
#: **标记删除**,删完立刻 query **仍能看到**该行,约 **1 秒**后才不可见
#: (`+1s` 起精确 filter 与空 filter 全量都查不到)。
#: 所以"删完立刻复核"必然谎报"没删掉"——本工具第一版就是这么错的(报 19 条残留,
#: 换个进程一跑却是 0 条)。复核必须轮询,不能删完立即断言。
VERIFY_WAIT_SECONDS = 1.5
VERIFY_ATTEMPTS = 4
def _is_semantic_id(doc_id: str) -> bool:
"""语义 id(`POL-AST-009-07`)→ True;上传路径的数字 id(`179`)→ False。"""
return not doc_id.isdigit()
def valid_contents() -> set[str]:
"""修正后产物的正文集合(白名单)。文件不存在时明确失败,不"当成空集"全删。"""
if not CHUNKS.exists():
raise SystemExit(
f"找不到 {CHUNKS}:请先跑 python tools/build_knowledge_chunks.py 生成正确产物。\n"
"(本工具靠它区分「该有的」与「表头碎片」;缺了它会退化成「删掉所有种子向量」。)"
)
return {
str(json.loads(line)["content"])
for line in CHUNKS.read_text(encoding="utf-8").splitlines()
if line.strip()
}
def collect() -> tuple[dict[str, list[str]], dict[str, Any]]:
"""返回 `({collection: [待删 doc_id]}, 明细)`;**只读**,不删任何东西。"""
from pymilvus import MilvusClient # type: ignore[import-untyped]
allowed = valid_contents()
settings = get_settings()
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
targets: dict[str, list[str]] = {}
detail: dict[str, Any] = {"collections": {}, "checked_at": datetime.now(UTC).isoformat()}
for name in sorted(ALLOWED_COLLECTIONS):
schema = detect_schema(client, name)
if not schema.usable:
detail["collections"][name] = {"error": schema.error or "集合不可用"}
continue
id_field = schema.resolve("doc_id")
content_field = schema.resolve("content")
rows = client.query(
collection_name=name, filter="",
output_fields=[id_field, content_field], limit=16384,
)
semantic = [
{"doc_id": str(row[id_field]), "content": str(row.get(content_field) or "")}
for row in rows
if _is_semantic_id(str(row[id_field]))
]
doomed = [item for item in semantic if item["content"] not in allowed]
if doomed:
targets[name] = [item["doc_id"] for item in doomed]
by_content: dict[str, list[str]] = {}
for item in doomed:
by_content.setdefault(item["content"], []).append(item["doc_id"])
detail["collections"][name] = {
"semantic_id_vectors": len(semantic),
"doomed": len(doomed),
"groups": [
{"content": content, "count": len(ids), "doc_ids": ids}
for content, ids in sorted(by_content.items(), key=lambda kv: -len(kv[1]))
],
}
return targets, detail
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="删除表格表头碎片向量(默认 dry-run)")
parser.add_argument("--apply", action="store_true", help="真正删除(默认只列出)")
parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="证据落盘路径")
args = parser.parse_args(argv)
targets, detail = collect()
total = sum(len(ids) for ids in targets.values())
print("表格表头碎片向量清理(判定:语义 id 且正文不在修正后产物里)")
print(f"修正后产物:{CHUNKS}({len(valid_contents())} 条正文白名单)\n")
if total == 0:
print("没有需要清理的向量(数据库已经是干净状态)。")
return 0
for name, item in detail["collections"].items():
if not isinstance(item, dict) or "error" in item:
print(f"⚠️ {name}: {item}")
continue
print(f"{name}: 语义 id 向量 {item['semantic_id_vectors']} 条,判定待删 {item['doomed']} 条")
for group in item["groups"]:
print(f" {group['count']:>3} 份 {group['content'][:56]!r}")
print(f" {group['doc_ids'][:8]}{' …' if group['count'] > 8 else ''}")
print(f"\n合计待删 {total} 条。")
if not args.apply:
print("\n[dry-run] 未删除。加 --apply 真删(并导出证据到 "
f"{args.out})。")
return 0
from pymilvus import MilvusClient # type: ignore[import-untyped]
settings = get_settings()
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
deleted = 0
for name, ids in targets.items():
# 与写路径同一个调用(`MilvusKnowledgeWriter.delete` 里也是 client.delete(ids=[...]))
client.delete(collection_name=name, ids=ids)
deleted += len(ids)
print(f" 已删 {name}: {len(ids)} 条")
detail["deleted"] = deleted
detail["applied"] = True
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(detail, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n共删除 {deleted} 条;证据(含全部 doc_id 与正文,可据此重建)已写入 {args.out}")
after_left = total
for attempt in range(VERIFY_ATTEMPTS):
time.sleep(VERIFY_WAIT_SECONDS)
after_targets, _ = collect()
after_left = sum(len(ids) for ids in after_targets.values())
if after_left == 0:
break
if after_left == 0:
print(f"复核:待删向量已清零 ✅(第 {attempt + 1} 次轮询,"
f"每次间隔 {VERIFY_WAIT_SECONDS}s —— Milvus 标记删除约 1 秒后才不可见)")
else:
print(f"⚠️ 复核:仍有 {after_left} 条待删向量,轮询 {VERIFY_ATTEMPTS} 次仍未清零。"
"请确认 Milvus 可写、以及删除语句没有报错(本工具未吞异常)。")
return 1 if after_left else 0
if __name__ == "__main__":
raise SystemExit(main())