Files
group_fqcd_jr/tools/reconcile_knowledge_vectors.py
T
张胜宇 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

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