"""知识库「向量 ↔ 元数据」对账(**只读**,不写 Milvus、不改 MySQL)。 python tools/reconcile_knowledge_vectors.py # 人读摘要 python tools/reconcile_knowledge_vectors.py --json # 机器读(含全部 id 明细) python tools/reconcile_knowledge_vectors.py --out docs/evidence/xxx.json ## 为什么要这个工具 知识的**元数据在 MySQL**(`fin_knowledge_meta`),**正文与向量在 Milvus**,两侧靠 `id`(Milvus 侧的物理字段名是 `doc_id` 或 `knowledge_id`,因环境而异,**运行时探测**)关联。 这个关联**没有任何数据库层约束**,因此会以四种方式坏掉,而**每一种在接口层都不报错**: | 分类 | 现象 | 为什么会发生 | |---|---|---| | `orphan_vectors` | 向量在 Milvus、MySQL 里没有这个 id | 知识行被物理删除过(早期脚本) | | `stale_vectors` | 向量在 Milvus、MySQL 行是 `expired` | 行是"别的途径"变成 expired 的,**从没投过删除事件** —— 这正是管理端口 `POST /api/v1/knowledge/{id}/vector-cleanups` 要清理的那批 | | `missing_vectors` | MySQL 行是 `active`、Milvus 里没有向量 | 同步事件没被消费(Worker 没跑 / 事件 `dead`),或向量集合被重建过 | | `duplicate_groups` | 同一 `source_file` + 集合有多条 active 行 / 正文完全相同的多行 | 导入侧幂等上线前重复入库;手工灌库 | **为什么必须查这个而不是"看检索结果对不对"**:检索侧只过滤 `visibility`、**不看 `status`**, 所以 `stale_vectors` 会**继续参与排序**并和其他副本抢答 —— 表现出来只是"客服答得不好/总转人工", 从任何一个接口都看不出向量库里多了一堆死向量(实测:产品手册被重复入库 7 次, 175 条历史副本把「风险等级 R1–R5」这类问题卡在"领先次优不够"的门槛下)。 ## 口径(三条,缺一条结论就会误导人) 1. **非数字 id 单独一类(`unbacked_seed_vectors`)**:`faq` / `policy` 两个集合里有一批 `FAQ-0013` 之类的**人工语义 id**,它们**本来就不该出现在 MySQL**(由 `tools/load_knowledge_milvus.py` / 灌库脚本直接写 Milvus)。把它们算成 `orphan_vectors` 会让人"清理孤儿"时把**唯一正确的答案**删掉 —— 本工具单独列出,且**不建议删**。 2. **只处理三个知识集合**:长期记忆向量(`user_long_term_memory_v1`)不属于知识库, 不在这里的统计范围内。 3. **只读**:本工具不修任何东西。发现 `stale_vectors` 后,用管理端口的 vector-cleanups 补投事件(见 `tools/purge_expired_knowledge_vectors.py`),由 Worker 真正删除 —— 工具自己去删向量会绕过权限、审计与 Outbox 语义。 """ from __future__ import annotations import argparse import hashlib import json import sys from collections import defaultdict from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from app.core.knowledge_contracts import ALLOWED_COLLECTIONS # noqa: E402 #: Milvus `query` 的分页上限(各环境默认 16384);超过就分页取。 PAGE_SIZE = 16384 #: 「低信息量向量」的正文长度门槛:一个向量整篇正文不到这么长,不可能回答任何问题, #: 却照样进排序。实测 `fin_policy_collection` 里有 20 条正文都是 19 字的 #: 「第九条 问卷内容及评分标准:选项 分值」(灌库时把 markdown 表格切碎了) #: —— 它们对任何"问卷/评分"类提问都是**同分并列**,直接把客服的"领先次优 ≥0.07"卡死。 TINY_CONTENT_LENGTH = 40 #: 默认的只读证据落盘位置(与人读摘要无关,摘要始终打到 stdout)。 DEFAULT_OUT = Path("docs/evidence/knowledge-vectors-reconcile.json") def is_heading_only(content: str) -> bool: """整段正文只是一行 markdown 标题(`## 一、交易规则` 这种)。 这类向量**不可能回答任何问题**,却会在任何提到这几个字的提问上拿到不低的相似度 —— 它把"领先次优"的差值抹平,也让真正有内容的块排不到第一。 实测 `fin_product_collection` 里有 47 条正文不到 20 字、其中不少就是标题行, 来源是灌库脚本按行切 markdown 时把标题单独切成了块。 """ stripped = content.strip() if not stripped.startswith("#"): return False return len(stripped.lstrip("#").strip()) < 20 def mysql_rows() -> list[dict[str, Any]]: """读 `fin_knowledge_meta` 的全部行(只取对账需要的列,**不读正文**)。 不读 `content_text`:这份表里存着整篇正文,全表拉回来既慢又没必要 —— 正文重复度由 Milvus 侧算(那里本来就有正文/摘要字段)。 """ from urllib.parse import unquote, urlparse import pymysql # type: ignore[import-untyped] from app.core.config import get_settings parsed = urlparse(get_settings().mysql_dsn.replace("mysql+asyncmy://", "mysql+pymysql://")) connection = pymysql.connect( host=parsed.hostname or "127.0.0.1", port=parsed.port or 3306, user=unquote(parsed.username or ""), password=unquote(parsed.password or ""), database=(parsed.path or "/").lstrip("/"), charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) try: with connection.cursor() as cursor: cursor.execute( "SELECT id, knowledge_type, source_file, milvus_collection, version, status," " review_status, created_at FROM fin_knowledge_meta" ) return [dict(row) for row in cursor.fetchall()] finally: connection.close() def milvus_vectors(collections: list[str]) -> dict[str, Any]: """按集合取 (向量 id, 正文哈希) 明细,物理字段名**运行时探测**。 返回 `{"collections": {...}, "errors": {...}}`;单集合失败不中断其余 (与检索侧一贯口径一致:一个集合探测不了,不该让整份报告消失)。 """ from app.core.config import get_settings from app.core.knowledge_schema import detect_schema from pymilvus import MilvusClient # type: ignore[import-untyped] settings = get_settings() client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None) report: dict[str, Any] = {"collections": {}, "errors": {}} for name in collections: try: schema = detect_schema(client, name) if not schema.usable: report["errors"][name] = ( schema.error or f"缺少必需字段:{','.join(schema.missing_required)}" ) continue id_field = schema.resolve("doc_id") content_field = schema.resolve("content") assert id_field is not None and content_field is not None # schema.usable 已保证 entries: list[dict[str, Any]] = [] offset = 0 while True: rows = client.query( collection_name=name, filter="", output_fields=[id_field, content_field], limit=PAGE_SIZE, offset=offset, ) if not rows: break for row in rows: content = str(row.get(content_field) or "") entries.append({ "vector_id": str(row.get(id_field) or ""), "content_sha1": hashlib.sha1( content.encode("utf-8") ).hexdigest()[:12], "content_length": len(content), "heading_only": is_heading_only(content), }) if len(rows) < PAGE_SIZE: break offset += len(rows) report["collections"][name] = { "id_field": id_field, "content_field": content_field, "entries": entries, } except Exception as exc: # 单集合失败不影响其余 report["errors"][name] = f"{type(exc).__name__}: {exc}" return report def reconcile( rows: list[dict[str, Any]], vectors: dict[str, Any] ) -> dict[str, Any]: """把两侧数据算成一份对账结论(纯函数:不连库、不连 Milvus,便于单测)。 `rows` 是 `fin_knowledge_meta` 的行;`vectors` 是 `milvus_vectors()` 的返回。 """ by_collection: dict[str, list[dict[str, Any]]] = defaultdict(list) for row in rows: by_collection[str(row.get("milvus_collection") or "")].append(row) result: dict[str, Any] = {"collections": {}, "errors": dict(vectors.get("errors", {}))} for name, payload in vectors.get("collections", {}).items(): entries = payload["entries"] mysql_for_collection = by_collection.get(name, []) status_by_id = {int(row["id"]): str(row.get("status") or "") for row in mysql_for_collection} vector_ids = [entry["vector_id"] for entry in entries] numeric_ids = [vid for vid in vector_ids if vid.isdigit()] unbacked = [vid for vid in vector_ids if not vid.isdigit()] orphan = [vid for vid in numeric_ids if int(vid) not in status_by_id] stale = [ vid for vid in numeric_ids if status_by_id.get(int(vid), "") not in {"", "active"} ] active_ids = { int(row["id"]) for row in mysql_for_collection if str(row.get("status") or "") == "active" } missing = sorted(active_ids - {int(vid) for vid in numeric_ids}) # 正文完全相同的向量(同一份内容被灌了多次)——**不管状态**:检索侧不看 status, # 所以一条 expired 行的向量照样参与排序,和别人的副本抢答。 sha_groups: dict[str, list[str]] = defaultdict(list) for entry in entries: sha_groups[entry["content_sha1"]].append(entry["vector_id"]) duplicate_content = { sha: ids for sha, ids in sha_groups.items() if len(ids) > 1 } # **同一份文档还有几个 active 版本**:只看行数会误判 —— 一份文档正常会切成十几个 chunk # (实测 `场内基金产品手册.md` 一个版本就是 24 行)。真正的重复信号是 # **该文件的 active 行里出现了正文完全相同的两块**:那只能是同一份文档被入库了多次。 sha_by_vector = {entry["vector_id"]: entry["content_sha1"] for entry in entries} active_files: dict[str, list[str]] = defaultdict(list) for row in mysql_for_collection: if str(row.get("status") or "") != "active": continue active_files[str(row.get("source_file") or "")].append( sha_by_vector.get(str(int(row["id"])), "") ) duplicated_active_files = { file: sorted({sha for sha in shas if sha and shas.count(sha) > 1}) for file, shas in active_files.items() if any(sha and shas.count(sha) > 1 for sha in shas) } # 低信息量向量:正文短到不可能回答任何问题,却照样参与排序(见 TINY_CONTENT_LENGTH)。 tiny = [ entry["vector_id"] for entry in entries if entry["content_length"] < TINY_CONTENT_LENGTH ] heading_only = [ entry["vector_id"] for entry in entries if entry["heading_only"] ] result["collections"][name] = { "milvus_vectors": len(entries), "mysql_rows": len(mysql_for_collection), "mysql_active": len(active_ids), "active_files": len(active_files), "orphan_vectors": len(orphan), "stale_vectors": len(stale), "missing_vectors": len(missing), "unbacked_seed_vectors": len(unbacked), "duplicate_content_groups": len(duplicate_content), "duplicate_content_vectors": sum(len(ids) for ids in duplicate_content.values()), "tiny_vectors": len(tiny), "heading_only_vectors": len(heading_only), "duplicated_active_files": len(duplicated_active_files), "detail": { "orphan_vectors": orphan[:200], "stale_vectors_ids": stale[:200], "missing_vectors": missing[:200], "unbacked_seed_vectors": unbacked[:200], "tiny_vectors": tiny[:100], "heading_only_vectors": heading_only[:100], "duplicated_active_files": { file: shas[:20] for file, shas in list(duplicated_active_files.items())[:50] }, "duplicate_content_groups": { sha: ids[:20] for sha, ids in list(duplicate_content.items())[:50] }, }, } return result def summarize(report: dict[str, Any]) -> str: """人读摘要。每条都带"下一步该做什么",避免只给一堆数字。""" lines: list[str] = ["知识库「向量 ↔ 元数据」对账(只读)", ""] header = ( f"{'集合':<24}{'向量':>7}{'MySQL行':>9}{'active':>8}{'文档数':>8}" f"{'孤儿':>7}{'死向量':>8}{'缺向量':>8}{'无元数据种子':>13}{'重复正文':>9}{'碎片':>7}{'纯标题':>7}" ) lines.append(header) lines.append("-" * len(header)) for name, item in report.get("collections", {}).items(): lines.append( f"{name:<24}{item['milvus_vectors']:>7}{item['mysql_rows']:>9}" f"{item['mysql_active']:>8}{item.get('active_files', 0):>8}" f"{item['orphan_vectors']:>7}" f"{item['stale_vectors']:>8}{item['missing_vectors']:>8}" f"{item['unbacked_seed_vectors']:>13}" f"{item.get('duplicate_content_vectors', 0):>9}{item.get('tiny_vectors', 0):>7}" f"{item.get('heading_only_vectors', 0):>7}" ) for name, error in report.get("errors", {}).items(): lines.append(f"⚠️ {name} 探测失败:{error}") lines.append("") totals = report.get("collections", {}).values() stale_total = sum(item["stale_vectors"] for item in totals) orphan_total = sum(item["orphan_vectors"] for item in totals) missing_total = sum(item["missing_vectors"] for item in totals) dup_files = sum(item.get("duplicated_active_files", 0) for item in totals) dup_vectors = sum(item.get("duplicate_content_vectors", 0) for item in totals) tiny_total = sum(item.get("tiny_vectors", 0) for item in totals) heading_total = sum(item.get("heading_only_vectors", 0) for item in totals) lines.append("怎么读这几列:") lines.append( f" · 死向量 {stale_total} 条:MySQL 里已是 expired,向量却还在,**会继续参与检索排序**。" ) lines.append(" 处理:python tools/purge_expired_knowledge_vectors.py --apply(走管理端口补投删除事件)") lines.append( f" · 孤儿向量 {orphan_total} 条:MySQL 里连行都没有(历史物理删除的残留),处理同「死向量」。" ) lines.append( f" · 缺向量 {missing_total} 条:active 行没有向量 → 这份知识**检索永远命中不到**。" ) lines.append(" 处理:确认 Worker 在跑,再重传一次该文档让同步事件重投(最省事)。") lines.append( f" · 重复正文 {dup_vectors} 条向量:正文逐字相同的多份,检索时**必然互相打平**," "是「领先次优 ≥0.07」被卡死的直接来源。" ) lines.append( f" (其中「同一份文档还有多个 active 版本」{dup_files} 份 → 重传一次该文件即可收敛)" ) lines.append( f" · 碎片 {tiny_total} 条:整篇正文不到 {TINY_CONTENT_LENGTH} 字的向量,回答不了任何问题," "却照样进排序、并和同题其它块打平(实测来源是灌库时把 markdown 表格切碎)。" ) lines.append(" 处理:属**灌库脚本的切分缺陷**,要在灌库侧修;不要为此改检索层的阈值。") lines.append( f" · 纯标题 {heading_total} 条:整段正文就是一行 markdown 标题(`## 一、交易规则`)。" " 这类向量**不可能回答任何问题**,却会在提到这几个字的提问上拿到不低的相似度。" ) lines.append( " 「文档数」列是一个集合里有多少份 active 文档(不是行数):一份文档正常切成十几块。" ) lines.append( " · 无元数据种子向量:`FAQ-0013` 这类人工语义 id,**本来就不在 MySQL**,不要当孤儿清理。" ) lines.append( " · 「向量」列来自 Milvus `query` 的**实际行数**;`get_collection_stats` 的 row_count" " 会把已软删、尚未 compaction 的行也算进去,故两者不等是正常的,以本列为准。" ) return "\n".join(lines) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="知识库向量-元数据对账(只读)") parser.add_argument("--json", action="store_true", help="打印完整 JSON(含 id 明细)") parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help="证据落盘路径") parser.add_argument("--no-write", action="store_true", help="不落盘,只打印") args = parser.parse_args(argv) rows = mysql_rows() vectors = milvus_vectors(sorted(ALLOWED_COLLECTIONS)) report = reconcile(rows, vectors) report["mysql_rows_total"] = len(rows) report["collections_checked"] = sorted(ALLOWED_COLLECTIONS) if not args.no_write: args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2)) else: print(summarize(report)) if not args.no_write: print(f"\n明细已写入 {args.out}") # 退出码只表达"能不能判定",不表达"有没有问题":对账发现残留是常态,不该让 CI 红。 return 1 if report.get("errors") else 0 if __name__ == "__main__": raise SystemExit(main())