## 根因(两个问题叠加) 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,否则两套编号会共存。
198 lines
9.3 KiB
Python
198 lines
9.3 KiB
Python
"""删除「表格表头被误当数据行」产生的零信息量知识向量(一次性清理;**默认 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())
|