RAG 向量索引重建与回归

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_ALLOWEDVECTOR_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 后,工程上还差一步:把向量索引当成有版本的制品,而不是"切一次分片、以后只改文档"。

常见缺口有三类:

  1. 文档已改、索引未重建payment_callback 字段从 callback_timeout_ms 换成别的名字,旧索引仍返回旧片段。
  2. 扩展表改了却不重算:同义词表增删后,变体层命中率变化,线上仍用旧向量权重。
  3. 无对比基线:重建后只看"好像能搜到",没有相对上一版索引的退步问法清单。

图1. 文档与扩展表变更后,索引指纹必须对齐,再谈切换线上指针。

本篇硬规则:

文档指纹或扩展表指纹与索引包不一致时,必须重建并过 VECTOR_INDEX_ALLOWED;未达标不得切换线上索引指针。


2. 先说结论

本篇用到的说法,先统一说明:

说法 含义
索引包 一次构建产出的目录:切分结果、向量、元数据与指纹
文档指纹 对文档清单做哈希,判断文档目录是否相对索引变更
扩展表指纹 对查询扩展 JSON 做哈希,判断同义词配置是否变更
漂移 当前文档或扩展表与索引包指纹不一致
原话层 接近文档表述的问法,验收前三条检索命中率
变体层 口语 / 同义词问法,验收排名第一命中率
步骤 动作 通过标准
1 构建基线索引包并评测 得到 baseline_eval.json
2 对变更目录跑漂移检测 INDEX_DRIFT_DETECTEDINDEX_FINGERPRINT_OK
3 重建当前索引包 写出 index_meta.json
4 原话层 + 变体层评测 命中率写入 current_eval.json
5 与基线对比并过闸门 VECTOR_INDEX_ALLOWED

四条落地判断:

  1. 切分参数写进元数据 ------max_chars / overlap 变更也算索引变更。
  2. 漂移先于重建------先证明"为什么要重建",再动构建脚本。
  3. 双层评测缺一不可------原话层过了、变体层掉了,一样阻断。
  4. 闸门通过才切指针 ------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.jsonlvectors.jsonindex_meta.json
  • 重建后 check_index_drift 对当前文档与扩展表输出 INDEX_FINGERPRINT_OK
  • 原话层前三条检索命中率 ≥ 策略阈值
  • 变体层排名第一命中率 ≥ 策略阈值
  • 相对基线退步问法条数 ≤ max_regressed_questions

闸门

  • vector_index_gate 输出 VECTOR_INDEX_ALLOWED
  • 未达标时不得切换线上索引指针
  • 发布记录归档 compare_report.jsonindex_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,下次无法判断是否漂移,只能靠人工记忆。

检索切换建议只说明"值得上向量";本篇的 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. 小结

向量检索上线后,发布动作从"改文档"变成"重建索引包并回归":

  1. 用指纹证明文档或扩展表已漂移
  2. 重建带元数据的索引包
  3. 对原话层与变体层做双层评测
  4. 与基线对比退步问法
  5. 闸门输出 VECTOR_INDEX_ALLOWED 后再切指针

上文已给出策略、构建、漂移检测、评测、对比、闸门与一键脚本全文。可把本阶段接到《RAG 文档更新后的全链路闸门验收》中:在检索切换建议之后增加向量索引重建闸门,再进入总闸门。


16. 相关阅读

《RAG 关键词检索与向量检索的切换边界》回答何时引入向量检索;《RAG 向量索引重建与回归》回答引入之后如何版本化重建。索引闸门稳定后,再谈真实 embedding 模型替换才不会把评测口径一起打乱。

如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。

相关推荐
HIT_Weston1 小时前
206、【Agent】【OpenCode】TUI 内部:装配层与 context 工厂
人工智能·agent·opencode
IT_陈寒1 小时前
Java字符串判等踩坑记:==和equals真的不能乱用
前端·人工智能·后端
生态学者1 小时前
香港理工大学Nature Communications:沿海塑料际古菌组特征及生态影响
大数据·人工智能·算法·r语言·微信公众平台
u1301301 小时前
AI 日报(2026年9月5日)
人工智能
Mycdn_WD1 小时前
从 AI 爬虫变多了到 AI 抓取开始分流,CDN 行业进入下一阶段
人工智能·爬虫·cdn·pcdn·城域网·pcdn资源招募
动物园猫1 小时前
铁路障碍物目标检测数据集:4类别、5,500+张图像 | 目标检测
人工智能·目标检测·计算机视觉
ly-272531 小时前
IEEE PDF eXpress终稿检测踩坑记录:PDF图片字体未嵌入与LaTeX参考文献编译异常解决方法
服务器·人工智能·算法
陕西企来客1 小时前
2026年8月咸阳家用雨棚上门测量怎么选
大数据·人工智能·咸阳家用雨棚上门测量