① 只读对账 tools/reconcile_knowledge_vectors.py
按集合列出:孤儿向量 / 死向量 / 缺向量 / 重复正文 / 低信息量碎片 / 纯标题。
关键口径:非数字 id(FAQ-0013 这类语义 id)是灌库脚本有意写进 Milvus 的,
单独归类、不建议删;向量数取自 query 实际行数,不用 get_collection_stats
(后者含已软删未 compaction 的行)。
② 导入侧幂等:同 source_file + 集合重传 = 覆盖上一版
app/service/knowledge_ingest_service.py 新增 _supersede_previous_version:
把上一版 active 行置为 expired,并逐行投 knowledge.vector_delete_requested
(与本次入库同事务)。写入侧只认 active 而检索侧不看 status,旧向量不清掉
会继续参与排序、和同题活块抢答。
顺带修掉一个真 bug:改为先判 chunks 非空再下线 —— 否则传一份解析出 0 块的
文档会把上一版下架、新版一行没写,这份文档在检索侧凭空消失。
③ 清理入口:POST /api/v1/knowledge/{knowledge_id}/vector-cleanups
给历史上"被别的途径置为 expired、从未投过删除事件"的行补投向量清理。
DELETE 对已过期行返回 404 的口径保持不变(重复删除静默成功会让调用方
分不清"这次真下线了"和"早就过期了"),因此新开一个语义明确的端点:
不存在 404 / 仍是 active 422(请改用 DELETE)/ 已 expired 200 并回传事件名。
配套 tools/purge_expired_knowledge_vectors.py(默认 dry-run)批量驱动该端点。
文档:docs/演示用/知识库向量对账与清理-2026-09-15.md(含真机验证输出),
并对 docs/演示用/知识库问答诊断-2026-09-14.md 做两处更正 —— 实测孤儿向量 0 条、
那 175 行历史副本从来没有向量(不参与排序),当时的差额来自 get_collection_stats
把已软删行算进去。
新发现(未修,需业务拍板):661 条向量里 451 条正文不到 40 字,是灌库时把
markdown 表格/标题切碎产生的碎片。「风险评估问卷怎么评分」实测前 4 名是 4 条
一模一样的 19 字碎片(gap 0.0024),真正 2828 字的答案排第 5 → 客服必然转人工。
属灌库切分缺陷,补内容救不了,也不应靠放宽 MIN_GAP 解决。
验证:pytest tests/unit tests/contract → 1500 passed, 2 skipped, 0 failed;
mypy app → 3 个错全在组员文件中(与本次改动无关);ruff 本次改动文件 0 错。
真机端到端:重传 → 旧行 expired + 删除事件 published + 旧向量已从 Milvus 删除;
两个问句回归仍正常回答(r1到r5 gap 0.0766;申购确认 0.8453)。
159 lines
7.1 KiB
Python
159 lines
7.1 KiB
Python
"""批量补投「向量残留清理」:把**已过期但向量还在**的知识逐条送去清理(走管理接口)。
|
||
|
||
python tools/purge_expired_knowledge_vectors.py # 只读:列出要清理哪些
|
||
python tools/purge_expired_knowledge_vectors.py --apply # 真投(每条一个 HTTP 调用)
|
||
python tools/purge_expired_knowledge_vectors.py --apply --wait 30 # 投完等 Worker 清完再复核
|
||
|
||
## 它解决什么
|
||
|
||
`fin_knowledge_meta.status = 'expired'` 只说明"这行下架了",**不代表 Milvus 里的向量没了**。
|
||
向量删除靠一条 `knowledge.vector_delete_requested` 事件,历史上有一批行是**别的途径**变成
|
||
expired 的(早期脚本直接改库、导入侧幂等上线前的重复副本),它们**从来没投过**这条事件:
|
||
|
||
- 检索侧只过滤 `visibility`、**不看 `status`**(`app/service/knowledge_search_service.py`),
|
||
所以这些死向量**继续参与排序**,和同题的活块抢答;
|
||
- 而 `DELETE /api/v1/knowledge/{id}` 对已经 expired 的行**刻意返回 404**("不静默成功"),
|
||
于是这些行在管理端口上**无路可走** —— 本脚本 + 新端点 `POST /{id}/vector-cleanups`
|
||
就是补这个缺口。
|
||
|
||
## 与对账工具的关系
|
||
|
||
先用 `python tools/reconcile_knowledge_vectors.py` 看全局,再用本脚本动手。本脚本**只清理
|
||
对账认定"确实还有向量"的那些 id**(`stale_vectors_ids`)—— 对一堆早就干净的 expired 行
|
||
盲投事件,只会往 `domain_event_outbox` 里灌没有意义的删除事件,让"事件堆积"变成常态噪音。
|
||
|
||
## 为什么不直接连 Milvus 删
|
||
|
||
那样会**同时绕过**权限、审计与 Outbox 幂等语义(删除是不可逆动作,必须留痕、必须有唯一入口)。
|
||
孤儿向量(MySQL 里连行都没有的那种)没有可挂事件的载体,本脚本**默认不处理**,
|
||
只在对账报告里点出来 —— 真要删属于"直接操作向量库"的运维动作,须单独授权。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import importlib.util
|
||
import sys
|
||
import time
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(errors="replace")
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
BASE_DEFAULT = "http://127.0.0.1:8000"
|
||
|
||
|
||
def _load_reconcile_module() -> Any:
|
||
"""按文件路径加载对账工具(`tools/` 不是包,没有 `__init__.py`,不能 `import tools.x`)。"""
|
||
path = ROOT / "tools" / "reconcile_knowledge_vectors.py"
|
||
spec = importlib.util.spec_from_file_location("reconcile_knowledge_vectors", path)
|
||
if spec is None or spec.loader is None: # pragma: no cover - 只在文件缺失时发生
|
||
raise SystemExit(f"找不到对账工具:{path}")
|
||
module = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(module)
|
||
return module
|
||
|
||
|
||
def _headers(token: str) -> dict[str, str]:
|
||
return {"Authorization": f"Bearer {token}", "Idempotency-Key": uuid.uuid4().hex}
|
||
|
||
|
||
def _login(client: httpx.Client, base: str, username: str, password: str) -> str:
|
||
response = client.post(
|
||
f"{base}/api/v1/auth/tokens", json={"username": username, "password": password}
|
||
)
|
||
if response.status_code != 200:
|
||
raise SystemExit(f"登录失败:HTTP {response.status_code} {response.text[:160]}")
|
||
return str(response.json()["data"]["access_token"])
|
||
|
||
|
||
def stale_ids_by_collection() -> dict[str, list[str]]:
|
||
"""本机对账一次,返回「还有向量的已过期行」:`{集合: [knowledge_id, ...]}`。"""
|
||
reconcile = _load_reconcile_module()
|
||
report = reconcile.reconcile(
|
||
reconcile.mysql_rows(), reconcile.milvus_vectors(sorted(_collections()))
|
||
)
|
||
return {
|
||
name: list(item["detail"]["stale_vectors_ids"])
|
||
for name, item in report.get("collections", {}).items()
|
||
if item["detail"]["stale_vectors_ids"]
|
||
}
|
||
|
||
|
||
def _collections() -> list[str]:
|
||
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS
|
||
|
||
return sorted(ALLOWED_COLLECTIONS)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(description="补投已过期知识的向量清理(走管理接口)")
|
||
parser.add_argument("--base", default=BASE_DEFAULT, help="平台地址")
|
||
parser.add_argument("--username", default="admin_t")
|
||
parser.add_argument("--password", default="88888888")
|
||
parser.add_argument("--apply", action="store_true", help="真正投递(默认只列出要清理的)")
|
||
parser.add_argument("--wait", type=int, default=0, metavar="秒",
|
||
help="投完等 Worker 消费,再复核一次残留数")
|
||
parser.add_argument("--limit", type=int, default=0, help="最多处理多少条(0=不限)")
|
||
args = parser.parse_args(argv)
|
||
|
||
stale = stale_ids_by_collection()
|
||
total = sum(len(ids) for ids in stale.values())
|
||
if total == 0:
|
||
print("对账结果:没有任何「已过期但向量还在」的知识行,无需清理。")
|
||
print("(要看全局请跑 python tools/reconcile_knowledge_vectors.py)")
|
||
return 0
|
||
|
||
print(f"对账发现 {total} 条已过期知识仍有向量(检索侧不看 status,它们仍在参与排序):")
|
||
targets: list[str] = []
|
||
for name, ids in stale.items():
|
||
print(f" · {name}: {len(ids)} 条 → {ids[:12]}{' …' if len(ids) > 12 else ''}")
|
||
targets.extend(ids)
|
||
if args.limit > 0:
|
||
targets = targets[: args.limit]
|
||
print(f"(--limit {args.limit}:本次只处理前 {len(targets)} 条)")
|
||
|
||
if not args.apply:
|
||
print("\n[dry-run] 未投递。加 --apply 真投(每条一次 POST /api/v1/knowledge/{id}/vector-cleanups)。")
|
||
return 0
|
||
|
||
with httpx.Client(base_url=args.base, timeout=60) as client:
|
||
token = _login(client, args.base, args.username, args.password)
|
||
ok = 0
|
||
failed: list[tuple[str, str]] = []
|
||
for knowledge_id in targets:
|
||
response = client.post(
|
||
f"{args.base}/api/v1/knowledge/{knowledge_id}/vector-cleanups",
|
||
headers=_headers(token),
|
||
)
|
||
if response.status_code == 200:
|
||
ok += 1
|
||
else:
|
||
failed.append((knowledge_id, f"HTTP {response.status_code} {response.text[:120]}"))
|
||
print(f"\n投递完成:成功 {ok} / 失败 {len(failed)}")
|
||
for knowledge_id, reason in failed[:10]:
|
||
print(f" ✗ {knowledge_id}: {reason}")
|
||
|
||
if args.wait > 0:
|
||
print(f"等 {args.wait} 秒让 Worker 消费删除事件 …")
|
||
time.sleep(args.wait)
|
||
remaining = stale_ids_by_collection()
|
||
left = sum(len(ids) for ids in remaining.values())
|
||
print(f"复核:仍有向量的已过期行 {left} 条"
|
||
+ ("" if left == 0 else f" → { {k: v[:8] for k, v in remaining.items()} }"))
|
||
if left > 0:
|
||
print(" 提示:确认 Worker 在跑(python -m app.worker),并看 domain_event_outbox 里"
|
||
" knowledge.vector_delete_requested 是否卡在 failed/dead。")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|