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

159 lines
7.1 KiB
Python
Raw Permalink 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())