Files
group_fqcd_jr/tools/reconcile_knowledge_vectors.py
T

374 lines
18 KiB
Python
Raw Normal View History

"""知识库「向量 ↔ 元数据」对账(**只读**,不写 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())