1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富 统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」; 同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。 2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本), 新增《文档规整方案与开发前待决事项-2026-09-17》。 3) 客服agent 四份交付文档首次纳入本分支。
374 lines
18 KiB
Python
374 lines
18 KiB
Python
"""知识库「向量 ↔ 元数据」对账(**只读**,不写 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())
|