RAG 向量索引重建与回归
文章目录
- [RAG 向量索引重建与回归](#RAG 向量索引重建与回归)
-
- [1. 切换建议通过不等于索引可以长期不重建](#1. 切换建议通过不等于索引可以长期不重建)
- [2. 先说结论](#2. 先说结论)
- [3. 演示:文档变更触发漂移](#3. 演示:文档变更触发漂移)
- [4. 演示:重建后闸门放行](#4. 演示:重建后闸门放行)
- [5. 演示:扩展表丢失导致阻断](#5. 演示:扩展表丢失导致阻断)
- [6. 策略与扩展表配置](#6. 策略与扩展表配置)
- [7. 构建带指纹的索引包](#7. 构建带指纹的索引包)
- [8. 漂移检测](#8. 漂移检测)
- [9. 双层评测与基线对比](#9. 双层评测与基线对比)
- [10. 重建闸门](#10. 重建闸门)
- [11. 一键重建与回归](#11. 一键重建与回归)
- [12. 人工核对清单](#12. 人工核对清单)
- [13. 常见错误](#13. 常见错误)
-
- [13.1 只改文档、不改索引指针](#13.1 只改文档、不改索引指针)
- [13.2 用文件名版本号当文档编号](#13.2 用文件名版本号当文档编号)
- [13.3 只评原话层](#13.3 只评原话层)
- [13.4 重建后不写指纹](#13.4 重建后不写指纹)
- [13.5 把 `RECOMMENDED` 当成已上线](#13.5 把
RECOMMENDED当成已上线) - [13.6 基线与当前切分参数不一致](#13.6 基线与当前切分参数不一致)
- [14. 术语速查](#14. 术语速查)
- [15. 小结](#15. 小结)
- [16. 相关阅读](#16. 相关阅读)
摘要 :《RAG 关键词检索与向量检索的切换边界》给出了切换建议,《RAG 文档更新后的全链路闸门验收》把五段闸门串成发布流水线。真正启用向量检索后,文档与扩展表一变,旧索引包仍在线上服务,变体问法会悄悄退步。本文说明如何把向量索引做成可版本化的索引包:写入文档指纹与扩展表指纹、检测漂移、重建后做原话层与变体层回归,并用闸门输出
VECTOR_INDEX_ALLOWED或VECTOR_INDEX_BLOCKED;标准库即可跑通。
说明 :承接 RAG 文档更新后的全链路闸门验收。上一篇解决"文档更新后按什么顺序跑闸门";本篇解决"向量索引何时必须重建、怎样回归才允许切换指针"。
承接前文:
建议目录:
bash
mkdir -p ~/kb-vector-index/{notes,scripts,configs,fixtures/{docs,docs_changed,eval},images,logs/vector_index}
cd ~/kb-vector-index
| 文件 | 作用 |
|---|---|
configs/vector_index_policy.example.json |
命中率与退步阈值 |
configs/query_expansion.example.json |
演示用查询扩展表 |
scripts/build_vector_index.py |
构建带指纹的索引包 |
scripts/check_index_drift.py |
文档 / 扩展表漂移检测 |
scripts/eval_vector_index.py |
原话层与变体层评测 |
scripts/vector_index_gate.py |
重建后放行闸门 |
scripts/run_vector_index_rebuild.py |
一键重建与回归 |
文中配置与脚本均全文给出。演示仍用 TF-IDF 余弦 + 查询扩展模拟向量检索;上线时可换成真实 embedding,指纹字段、评测口径与闸门输出保持不变。
1. 切换建议通过不等于索引可以长期不重建
《RAG 关键词检索与向量检索的切换边界》在变体层上给出 RETRIEVER_SWITCH_RECOMMENDED 后,工程上还差一步:把向量索引当成有版本的制品,而不是"切一次分片、以后只改文档"。
常见缺口有三类:
- 文档已改、索引未重建 :
payment_callback字段从callback_timeout_ms换成别的名字,旧索引仍返回旧片段。 - 扩展表改了却不重算:同义词表增删后,变体层命中率变化,线上仍用旧向量权重。
- 无对比基线:重建后只看"好像能搜到",没有相对上一版索引的退步问法清单。

图1. 文档与扩展表变更后,索引指纹必须对齐,再谈切换线上指针。
本篇硬规则:
文档指纹或扩展表指纹与索引包不一致时,必须重建并过 VECTOR_INDEX_ALLOWED;未达标不得切换线上索引指针。
2. 先说结论
本篇用到的说法,先统一说明:
| 说法 | 含义 |
|---|---|
| 索引包 | 一次构建产出的目录:切分结果、向量、元数据与指纹 |
| 文档指纹 | 对文档清单做哈希,判断文档目录是否相对索引变更 |
| 扩展表指纹 | 对查询扩展 JSON 做哈希,判断同义词配置是否变更 |
| 漂移 | 当前文档或扩展表与索引包指纹不一致 |
| 原话层 | 接近文档表述的问法,验收前三条检索命中率 |
| 变体层 | 口语 / 同义词问法,验收排名第一命中率 |
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | 构建基线索引包并评测 | 得到 baseline_eval.json |
| 2 | 对变更目录跑漂移检测 | INDEX_DRIFT_DETECTED 或 INDEX_FINGERPRINT_OK |
| 3 | 重建当前索引包 | 写出 index_meta.json |
| 4 | 原话层 + 变体层评测 | 命中率写入 current_eval.json |
| 5 | 与基线对比并过闸门 | VECTOR_INDEX_ALLOWED |
四条落地判断:
- 切分参数写进元数据 ------
max_chars/overlap变更也算索引变更。 - 漂移先于重建------先证明"为什么要重建",再动构建脚本。
- 双层评测缺一不可------原话层过了、变体层掉了,一样阻断。
- 闸门通过才切指针 ------
ALLOWED之前,线上仍指向旧索引包。

图2. 建基线 → 查漂移 → 重建 → 双层评测 → 闸门。
3. 演示:文档变更触发漂移
基线索引建在 fixtures/docs。把支付文档换成 fixtures/docs_changed(字段改为 payment_callback_timeout)后,不重建直接检测:
bash
python3 scripts/check_index_drift.py \
--index-dir logs/vector_index/index_baseline \
--docs-dir fixtures/docs_changed \
--expansion configs/query_expansion.example.json \
--out logs/vector_index/drift_before.json
本机输出:
INDEX_DRIFT_DETECTED
FAIL: docs fingerprint mismatch changed_docs=['payment_callback']

图3. 只有
payment_callback变更时,漂移报告会点名该文档编号。
这说明:不是"感觉文档变了",而是索引包已无法代表当前文档树。
4. 演示:重建后闸门放行
对当前 fixtures/docs 与完整扩展表执行一键流水线:
bash
python3 scripts/run_vector_index_rebuild.py \
--policy configs/vector_index_policy.example.json \
--docs-dir fixtures/docs \
--expansion configs/query_expansion.example.json \
--canonical fixtures/eval/questions_canonical.jsonl \
--paraphrase fixtures/eval/questions_paraphrase.jsonl \
--workdir . \
--check-docs-dir fixtures/docs_changed \
--label rebuilt_ok
本机输出节选:
INDEX_DRIFT_DETECTED
changed_docs=['payment_callback']
INDEX_BUILD_OK label=rebuilt_ok
INDEX_FINGERPRINT_OK
VECTOR_EVAL canonical(top3)=100.0% paraphrase(top1)=100.0%
COMPARE paraphrase 100.0% -> 100.0% drop=0.0% regressed=0
VECTOR_INDEX_ALLOWED
VECTOR_INDEX_REBUILD_OK
| 指标 | 结果 |
|---|---|
| 原话层前三条检索命中率 | 100%(4/4) |
| 变体层排名第一命中率 | 100%(5/5) |
| 相对基线退步条数 | 0 |
| 重建后指纹 | INDEX_FINGERPRINT_OK |

图4. 双层命中率达标且无退步,才输出
VECTOR_INDEX_ALLOWED。
5. 演示:扩展表丢失导致阻断
若重建时误用空扩展表 query_expansion.empty.json,而线上策略仍要求完整扩展:
VECTOR_EVAL canonical(top3)=100.0% paraphrase(top1)=80.0%
COMPARE paraphrase 100.0% -> 80.0% drop=20.0% regressed=1
VECTOR_INDEX_BLOCKED
FAIL: paraphrase_hit_rate 0.800 < min_paraphrase_hit_rate 0.85
FAIL: expansion fingerprint mismatch
FAIL: regressed_count 1 > max_regressed_questions 0
原话层仍可能 100%,变体层掉到 80%,问法 V2 退步。这说明:只盯原话层会放过坏索引。

图5. 变体层掉点与扩展指纹不一致同时出现时,闸门必须阻断。
6. 策略与扩展表配置
保存为 configs/vector_index_policy.example.json:
json
{
"min_canonical_hit_rate": 1.0,
"min_paraphrase_hit_rate": 0.85,
"max_regressed_questions": 0,
"max_hit_drop": 0.15,
"require_fingerprint_match": true,
"require_expansion_match": true,
"algorithm": "tfidf_cosine_v1",
"canonical_top_k": 3,
"paraphrase_top_k": 1,
"max_chars": 240,
"overlap_chars": 40
}
保存为 configs/query_expansion.example.json:
json
{
"token": ["token", "令牌", "凭证", "access token", "JWT", "Authorization"],
"身份验证": ["身份验证", "登录", "登陆", "鉴权", "auth"],
"刷新": ["刷新", "续期", "renew", "refresh"],
"回调": ["回调", "通知", "notify", "callback"],
"超时": ["超时", "等待", "latency", "timeout"],
"软链": ["软链", "符号链接", "current", "ln -sfn"],
"签名校验": ["签名校验", "验签", "签名", "sign"],
"回滚": ["回滚", "切回", "上一版本", "恢复"]
}
7. 构建带指纹的索引包
保存为 scripts/build_vector_index.py:
python
#!/usr/bin/env python3
"""Build a versioned vector index package from docs + expansion config."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
from retrieve_vector import build_vectors, load_chunks, load_expansion # noqa: E402
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(description="Build vector index package")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--expansion", type=Path, required=True)
parser.add_argument("--out-dir", type=Path, required=True)
parser.add_argument("--max-chars", type=int, default=240)
parser.add_argument("--overlap-chars", type=int, default=40)
parser.add_argument("--algorithm", default="tfidf_cosine_v1")
parser.add_argument("--label", default="current")
args = parser.parse_args()
out_dir = args.out_dir
out_dir.mkdir(parents=True, exist_ok=True)
chunks_path = out_dir / "chunks.jsonl"
meta_path = out_dir / "index_meta.json"
vectors_path = out_dir / "vectors.json"
manifest_path = out_dir / "docs_manifest.json"
py = sys.executable
subprocess.run(
[
py,
str(ROOT / "fingerprint_docs.py"),
"--docs-dir",
str(args.docs_dir),
"--out",
str(manifest_path),
],
check=True,
)
subprocess.run(
[
py,
str(ROOT / "chunk_docs.py"),
"--docs-dir",
str(args.docs_dir),
"--out",
str(chunks_path),
"--max-chars",
str(args.max_chars),
"--overlap-chars",
str(args.overlap_chars),
],
check=True,
)
chunks = load_chunks(chunks_path)
expansion = load_expansion(args.expansion)
vectors, idf = build_vectors(chunks)
# Persist sparse vectors as term->weight maps (demo-friendly, stdlib only)
vectors_payload = {
"algorithm": args.algorithm,
"idf": idf,
"vectors": vectors,
"chunk_ids": [c.get("chunk_id") for c in chunks],
}
vectors_path.write_text(json.dumps(vectors_payload, ensure_ascii=False) + "\n", encoding="utf-8")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
docs_fp = sha256_text(json.dumps(manifest, sort_keys=True, ensure_ascii=False))
expansion_fp = sha256_file(args.expansion)
chunks_fp = sha256_file(chunks_path)
meta = {
"label": args.label,
"algorithm": args.algorithm,
"built_at": datetime.now(timezone.utc).isoformat(),
"docs_dir": str(args.docs_dir.resolve()),
"expansion_path": str(args.expansion.resolve()),
"max_chars": args.max_chars,
"overlap_chars": args.overlap_chars,
"chunk_count": len(chunks),
"docs_fingerprint": docs_fp,
"expansion_fingerprint": expansion_fp,
"chunks_fingerprint": chunks_fp,
"docs_manifest": manifest,
}
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"INDEX_BUILD_OK label={args.label} chunks={len(chunks)} -> {out_dir}")
print(f"docs_fingerprint={docs_fp[:12]}... expansion_fingerprint={expansion_fp[:12]}...")
if __name__ == "__main__":
main()
索引包目录至少包含:
| 文件 | 作用 |
|---|---|
chunks.jsonl |
切分片段 |
vectors.json |
演示用稀疏向量与 IDF |
docs_manifest.json |
各文档 sha256 |
index_meta.json |
算法、切分参数、文档 / 扩展指纹、构建时间 |
8. 漂移检测
保存为 scripts/check_index_drift.py:
python
#!/usr/bin/env python3
"""Detect docs/expansion drift against a saved vector index package."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def sha256_file(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(description="Check vector index drift")
parser.add_argument("--index-dir", type=Path, required=True)
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--expansion", type=Path, required=True)
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
meta = json.loads((args.index_dir / "index_meta.json").read_text(encoding="utf-8"))
tmp_manifest = args.index_dir / "_live_manifest.json"
subprocess.run(
[
sys.executable,
str(ROOT / "fingerprint_docs.py"),
"--docs-dir",
str(args.docs_dir),
"--out",
str(tmp_manifest),
],
check=True,
capture_output=True,
)
live_manifest = json.loads(tmp_manifest.read_text(encoding="utf-8"))
live_docs_fp = sha256_text(json.dumps(live_manifest, sort_keys=True, ensure_ascii=False))
live_exp_fp = sha256_file(args.expansion)
docs_match = live_docs_fp == meta.get("docs_fingerprint")
exp_match = live_exp_fp == meta.get("expansion_fingerprint")
drifted = not (docs_match and exp_match)
changed_docs = []
indexed = {f["doc_id"]: f["sha256"] for f in (meta.get("docs_manifest") or {}).get("files") or []}
live = {f["doc_id"]: f["sha256"] for f in live_manifest.get("files") or []}
for doc_id in sorted(set(indexed) | set(live)):
if indexed.get(doc_id) != live.get(doc_id):
changed_docs.append(doc_id)
out = {
"drifted": drifted,
"docs_match": docs_match,
"expansion_match": exp_match,
"changed_docs": changed_docs,
"index_docs_fingerprint": meta.get("docs_fingerprint"),
"live_docs_fingerprint": live_docs_fp,
"index_expansion_fingerprint": meta.get("expansion_fingerprint"),
"live_expansion_fingerprint": live_exp_fp,
"index_label": meta.get("label"),
"index_built_at": meta.get("built_at"),
}
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print("INDEX_DRIFT_DETECTED" if drifted else "INDEX_FINGERPRINT_OK")
if not docs_match:
print(f"FAIL: docs fingerprint mismatch changed_docs={changed_docs}")
if not exp_match:
print("FAIL: expansion fingerprint mismatch")
raise SystemExit(2 if drifted else 0)
if __name__ == "__main__":
main()
9. 双层评测与基线对比
保存为 scripts/eval_vector_index.py:
python
#!/usr/bin/env python3
"""Evaluate a vector index package on canonical and paraphrase question sets."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run_eval(py: str, chunks: Path, questions: Path, expansion: Path, out: Path, top_k: int) -> dict:
cmd = [
py,
str(ROOT / "eval_retrieval.py"),
"--chunks",
str(chunks),
"--questions",
str(questions),
"--backend",
"vector",
"--expansion",
str(expansion),
"--top-k",
str(top_k),
"--out",
str(out),
]
subprocess.run(cmd, check=True)
return json.loads(out.read_text(encoding="utf-8"))
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate vector index package")
parser.add_argument("--index-dir", type=Path, required=True)
parser.add_argument("--canonical", type=Path, required=True)
parser.add_argument("--paraphrase", type=Path, required=True)
parser.add_argument("--expansion", type=Path, required=True)
parser.add_argument("--canonical-top-k", type=int, default=3)
parser.add_argument("--paraphrase-top-k", type=int, default=1)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
chunks = args.index_dir / "chunks.jsonl"
if not chunks.exists():
raise SystemExit(f"missing chunks: {chunks}")
py = sys.executable
out_dir = args.out.parent
out_dir.mkdir(parents=True, exist_ok=True)
c_report = run_eval(py, chunks, args.canonical, args.expansion, out_dir / "eval_canonical.json", args.canonical_top_k)
p_report = run_eval(py, chunks, args.paraphrase, args.expansion, out_dir / "eval_paraphrase.json", args.paraphrase_top_k)
summary = {
"index_dir": str(args.index_dir),
"canonical_top_k": args.canonical_top_k,
"paraphrase_top_k": args.paraphrase_top_k,
"canonical_hit_rate": c_report.get("top_k_hit_rate") or c_report.get("hit_at_k"),
"paraphrase_hit_rate": p_report.get("top_k_hit_rate") or p_report.get("hit_at_k"),
"canonical_failed_ids": c_report.get("failed_ids") or [d["id"] for d in c_report.get("details", []) if not d.get("ok")],
"paraphrase_failed_ids": p_report.get("failed_ids") or [d["id"] for d in p_report.get("details", []) if not d.get("ok")],
"canonical_total": c_report.get("total"),
"paraphrase_total": p_report.get("total"),
}
args.out.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
print(
f"VECTOR_EVAL canonical(top{args.canonical_top_k})={summary['canonical_hit_rate']:.1%} "
f"paraphrase(top{args.paraphrase_top_k})={summary['paraphrase_hit_rate']:.1%}"
)
if __name__ == "__main__":
main()
保存为 scripts/compare_index_evals.py:
python
#!/usr/bin/env python3
"""Compare baseline vs current vector index eval summaries."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def failed_set(report: dict, key: str) -> set[str]:
return set(report.get(key) or [])
def main() -> None:
parser = argparse.ArgumentParser(description="Compare vector index eval reports")
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--current", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
base = json.loads(args.baseline.read_text(encoding="utf-8"))
cur = json.loads(args.current.read_text(encoding="utf-8"))
base_fail = failed_set(base, "paraphrase_failed_ids") | failed_set(base, "canonical_failed_ids")
cur_fail = failed_set(cur, "paraphrase_failed_ids") | failed_set(cur, "canonical_failed_ids")
regressed = sorted(cur_fail - base_fail)
improved = sorted(base_fail - cur_fail)
c_drop = float(base.get("canonical_hit_rate") or 0) - float(cur.get("canonical_hit_rate") or 0)
p_drop = float(base.get("paraphrase_hit_rate") or 0) - float(cur.get("paraphrase_hit_rate") or 0)
out = {
"baseline_canonical_hit_rate": base.get("canonical_hit_rate"),
"current_canonical_hit_rate": cur.get("canonical_hit_rate"),
"baseline_paraphrase_hit_rate": base.get("paraphrase_hit_rate"),
"current_paraphrase_hit_rate": cur.get("paraphrase_hit_rate"),
"canonical_hit_drop": round(c_drop, 4),
"paraphrase_hit_drop": round(p_drop, 4),
"regressed_ids": regressed,
"improved_ids": improved,
"regressed_count": len(regressed),
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
print(
f"COMPARE canonical {base.get('canonical_hit_rate'):.1%} -> {cur.get('canonical_hit_rate'):.1%} "
f"drop={c_drop:.1%}"
)
print(
f"COMPARE paraphrase {base.get('paraphrase_hit_rate'):.1%} -> {cur.get('paraphrase_hit_rate'):.1%} "
f"drop={p_drop:.1%} regressed={len(regressed)}"
)
if __name__ == "__main__":
main()
10. 重建闸门
保存为 scripts/vector_index_gate.py:
python
#!/usr/bin/env python3
"""Gate vector index rebuild against policy thresholds."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Gate vector index rebuild")
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--eval-summary", type=Path, required=True)
parser.add_argument("--compare-report", type=Path, default=None)
parser.add_argument("--drift-report", type=Path, default=None)
parser.add_argument("--expect-no-drift", action="store_true")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
policy = json.loads(args.policy.read_text(encoding="utf-8"))
eval_s = json.loads(args.eval_summary.read_text(encoding="utf-8"))
errors: list[str] = []
min_c = float(policy.get("min_canonical_hit_rate", 1.0))
min_p = float(policy.get("min_paraphrase_hit_rate", 0.85))
c_hit = float(eval_s.get("canonical_hit_rate") or 0)
p_hit = float(eval_s.get("paraphrase_hit_rate") or 0)
if c_hit < min_c:
errors.append(f"canonical_hit_rate {c_hit:.3f} < min_canonical_hit_rate {min_c}")
if p_hit < min_p:
errors.append(f"paraphrase_hit_rate {p_hit:.3f} < min_paraphrase_hit_rate {min_p}")
if args.drift_report and args.drift_report.exists():
drift = json.loads(args.drift_report.read_text(encoding="utf-8"))
if args.expect_no_drift and drift.get("drifted"):
errors.append(f"index still drifted after rebuild changed_docs={drift.get('changed_docs')}")
if policy.get("require_fingerprint_match") and args.expect_no_drift and not drift.get("docs_match", True):
errors.append("docs fingerprint mismatch")
if policy.get("require_expansion_match") and args.expect_no_drift and not drift.get("expansion_match", True):
errors.append("expansion fingerprint mismatch")
if args.compare_report and args.compare_report.exists():
cmp = json.loads(args.compare_report.read_text(encoding="utf-8"))
max_reg = int(policy.get("max_regressed_questions", 0))
max_drop = float(policy.get("max_hit_drop", 1.0))
reg = int(cmp.get("regressed_count") or 0)
drop = max(float(cmp.get("canonical_hit_drop") or 0), float(cmp.get("paraphrase_hit_drop") or 0))
if reg > max_reg:
errors.append(f"regressed_count {reg} > max_regressed_questions {max_reg}")
if drop > max_drop:
errors.append(f"hit_drop {drop:.3f} > max_hit_drop {max_drop}")
allow = not errors
out = {
"allow": allow,
"canonical_hit_rate": c_hit,
"paraphrase_hit_rate": p_hit,
"errors": errors,
}
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print("VECTOR_INDEX_ALLOWED" if allow else "VECTOR_INDEX_BLOCKED")
for e in errors:
print(f"FAIL: {e}")
if allow:
print(f"canonical={c_hit:.1%} paraphrase={p_hit:.1%}")
raise SystemExit(0 if allow else 2)
if __name__ == "__main__":
main()
bash
python3 scripts/vector_index_gate.py \
--policy configs/vector_index_policy.example.json \
--eval-summary logs/vector_index/current_eval.json \
--compare-report logs/vector_index/compare_report.json \
--drift-report logs/vector_index/drift_after.json \
--expect-no-drift
11. 一键重建与回归
保存为 scripts/run_vector_index_rebuild.py:
python
#!/usr/bin/env python3
"""One-shot: build vector index, detect drift, evaluate, compare, gate."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run(cmd: list[str], cwd: Path, check: bool = True) -> tuple[int, str]:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
text = (proc.stdout or "") + (proc.stderr or "")
if text.strip():
print(text[-2000:])
if check and proc.returncode != 0:
raise SystemExit(proc.returncode)
return proc.returncode, text
def main() -> None:
parser = argparse.ArgumentParser(description="Rebuild and regress vector index")
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--expansion", type=Path, required=True)
parser.add_argument("--canonical", type=Path, required=True)
parser.add_argument("--paraphrase", type=Path, required=True)
parser.add_argument("--workdir", type=Path, default=Path("."))
parser.add_argument("--baseline-dir", type=Path, default=None)
parser.add_argument("--index-dir", type=Path, default=None)
parser.add_argument("--label", default="rebuilt")
parser.add_argument("--skip-baseline", action="store_true")
parser.add_argument("--check-docs-dir", type=Path, default=None, help="optional live docs for drift check before rebuild")
args = parser.parse_args()
workdir = args.workdir.resolve()
policy = json.loads(args.policy.read_text(encoding="utf-8"))
log = workdir / "logs/vector_index"
log.mkdir(parents=True, exist_ok=True)
py = sys.executable
baseline_dir = (args.baseline_dir or (log / "index_baseline")).resolve()
index_dir = (args.index_dir or (log / "index_current")).resolve()
max_chars = int(policy.get("max_chars", 240))
overlap = int(policy.get("overlap_chars", 40))
algo = policy.get("algorithm", "tfidf_cosine_v1")
c_k = int(policy.get("canonical_top_k", 3))
p_k = int(policy.get("paraphrase_top_k", 1))
# 1) baseline index (once)
if not args.skip_baseline:
if baseline_dir.exists():
shutil.rmtree(baseline_dir)
run(
[
py,
str(ROOT / "build_vector_index.py"),
"--docs-dir",
str(args.docs_dir),
"--expansion",
str(args.expansion),
"--out-dir",
str(baseline_dir),
"--max-chars",
str(max_chars),
"--overlap-chars",
str(overlap),
"--algorithm",
algo,
"--label",
"baseline",
],
workdir,
)
run(
[
py,
str(ROOT / "eval_vector_index.py"),
"--index-dir",
str(baseline_dir),
"--canonical",
str(args.canonical),
"--paraphrase",
str(args.paraphrase),
"--expansion",
str(args.expansion),
"--canonical-top-k",
str(c_k),
"--paraphrase-top-k",
str(p_k),
"--out",
str(log / "baseline_eval.json"),
],
workdir,
)
# 2) optional pre-rebuild drift against another docs dir
drift_path = log / "drift_before.json"
if args.check_docs_dir:
code, _ = run(
[
py,
str(ROOT / "check_index_drift.py"),
"--index-dir",
str(baseline_dir),
"--docs-dir",
str(args.check_docs_dir),
"--expansion",
str(args.expansion),
"--out",
str(drift_path),
],
workdir,
check=False,
)
if code == 0:
print("NOTE: no drift against check-docs-dir; rebuild still proceeds for demo refresh")
# 3) rebuild current index
if index_dir.exists():
shutil.rmtree(index_dir)
run(
[
py,
str(ROOT / "build_vector_index.py"),
"--docs-dir",
str(args.docs_dir),
"--expansion",
str(args.expansion),
"--out-dir",
str(index_dir),
"--max-chars",
str(max_chars),
"--overlap-chars",
str(overlap),
"--algorithm",
algo,
"--label",
args.label,
],
workdir,
)
# 4) post-rebuild drift must be clean
run(
[
py,
str(ROOT / "check_index_drift.py"),
"--index-dir",
str(index_dir),
"--docs-dir",
str(args.docs_dir),
"--expansion",
str(args.expansion),
"--out",
str(log / "drift_after.json"),
],
workdir,
)
# 5) eval rebuilt index
run(
[
py,
str(ROOT / "eval_vector_index.py"),
"--index-dir",
str(index_dir),
"--canonical",
str(args.canonical),
"--paraphrase",
str(args.paraphrase),
"--expansion",
str(args.expansion),
"--canonical-top-k",
str(c_k),
"--paraphrase-top-k",
str(p_k),
"--out",
str(log / "current_eval.json"),
],
workdir,
)
# 6) compare with baseline
run(
[
py,
str(ROOT / "compare_index_evals.py"),
"--baseline",
str(log / "baseline_eval.json"),
"--current",
str(log / "current_eval.json"),
"--out",
str(log / "compare_report.json"),
],
workdir,
)
# 7) gate
gate = run(
[
py,
str(ROOT / "vector_index_gate.py"),
"--policy",
str(args.policy),
"--eval-summary",
str(log / "current_eval.json"),
"--compare-report",
str(log / "compare_report.json"),
"--drift-report",
str(log / "drift_after.json"),
"--expect-no-drift",
],
workdir,
check=False,
)[0]
print("VECTOR_INDEX_REBUILD_OK" if gate == 0 else "VECTOR_INDEX_REBUILD_BLOCKED")
raise SystemExit(gate)
if __name__ == "__main__":
main()
成功时末尾输出 VECTOR_INDEX_REBUILD_OK。
12. 人工核对清单
保存为 notes/vector_index_checklist.md:
向量索引重建与回归核对清单
重建前
- 确认线上/预发使用的是向量检索(或混合检索),而不是只用关键词
- 文档目录、扩展表(query expansion)路径与策略文件已锁定版本
- 基线索引包
index_baseline/与baseline_eval.json已归档 - 原话层与变体层评测问法可用(承接《RAG 可回归评测问法的业务采集与标注》)
指纹与漂移
- 文档变更后先跑
check_index_drift,看到INDEX_DRIFT_DETECTED再重建 - 扩展表变更同样视为漂移(
expansion_fingerprint不一致) - 切分参数(
max_chars/overlap)变更视为索引变更
重建与评测
-
build_vector_index产出chunks.jsonl、vectors.json、index_meta.json - 重建后
check_index_drift对当前文档与扩展表输出INDEX_FINGERPRINT_OK - 原话层前三条检索命中率 ≥ 策略阈值
- 变体层排名第一命中率 ≥ 策略阈值
- 相对基线退步问法条数 ≤
max_regressed_questions
闸门
-
vector_index_gate输出VECTOR_INDEX_ALLOWED - 未达标时不得切换线上索引指针
- 发布记录归档
compare_report.json与index_meta.json
收尾
- 将新索引包设为下一轮基线
- 若接入全链路闸门,把本阶段插在检索切换建议之后、总闸门之前
13. 常见错误
13.1 只改文档、不改索引指针
文档仓库合并后,检索服务仍挂旧索引包目录。应先漂移检测,再重建,最后原子切换指针。
13.2 用文件名版本号当文档编号
payment_callback_v2.md 会产生新的 doc_id,题集 gold_docs 仍写 payment_callback 时命中率会假性崩溃。内容可以更新,文档编号应保持稳定。
13.3 只评原话层
扩展表丢失时,原话层仍可能满分。变体层才是向量索引的主验收面。
13.4 重建后不写指纹
没有 docs_fingerprint / expansion_fingerprint,下次无法判断是否漂移,只能靠人工记忆。
13.5 把 RECOMMENDED 当成已上线
检索切换建议只说明"值得上向量";本篇的 VECTOR_INDEX_ALLOWED 才说明"当前索引包可以切指针"。
13.6 基线与当前切分参数不一致
基线 240 字、当前 360 字,对比失去意义。切分参数必须写进 index_meta.json 并在策略里固定。
14. 术语速查
| 术语 | 含义 |
|---|---|
INDEX_DRIFT_DETECTED |
当前文档或扩展表与索引包指纹不一致 |
INDEX_FINGERPRINT_OK |
指纹对齐,索引代表当前输入 |
VECTOR_INDEX_ALLOWED |
重建后评测与对比达标,可切换指针 |
VECTOR_INDEX_BLOCKED |
命中率、退步或指纹未达标,禁止切换 |
| 查询扩展表 | 把口语同义词映射到文档用语的 JSON 配置 |
| 索引指针 | 线上服务当前加载的索引包目录或版本号 |
15. 小结
向量检索上线后,发布动作从"改文档"变成"重建索引包并回归":
- 用指纹证明文档或扩展表已漂移
- 重建带元数据的索引包
- 对原话层与变体层做双层评测
- 与基线对比退步问法
- 闸门输出
VECTOR_INDEX_ALLOWED后再切指针
上文已给出策略、构建、漂移检测、评测、对比、闸门与一键脚本全文。可把本阶段接到《RAG 文档更新后的全链路闸门验收》中:在检索切换建议之后增加向量索引重建闸门,再进入总闸门。
16. 相关阅读
《RAG 关键词检索与向量检索的切换边界》回答何时引入向量检索;《RAG 向量索引重建与回归》回答引入之后如何版本化重建。索引闸门稳定后,再谈真实 embedding 模型替换才不会把评测口径一起打乱。
如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。