Files
group_fqcd_jr/tools/drop_table_header_vectors.py
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

198 lines
9.3 KiB
Python
Raw Permalink 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.
"""删除「表格表头被误当数据行」产生的零信息量知识向量(一次性清理;**默认 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())