Files
group_fqcd_jr/tools/purge_expired_knowledge_vectors.py
T
lzf_0626 58b73ff594 知识库三项收口:向量-元数据对账 + 导入侧幂等 + 过期行向量清理入口
① 只读对账 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)。
2026-09-15 09:06:57 +08:00

159 lines
7.1 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.
"""批量补投「向量残留清理」:把**已过期但向量还在**的知识逐条送去清理(走管理接口)。
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())