Skip to content

第12章 · RAG 实战:真实 Embedding、向量库与混合检索

本页的 recall/引用/拒答数字来自本地真实运行的 bge-small-zh-v1.5(20 题小评估集),Gemini provider 只做了接口对齐、未在本环境实测(无 API key)。

先修:第11章(ACL 前置过滤、答案必带引用、无证据拒答三条纪律,本章一条都不能破)。

本章目标

  • 用真实 embedding(本地 BAAI/bge-small-zh-v1.5)替换 hash-vector 伪向量,且新旧接口签名完全一致。
  • 用 sqlite-vec 把 chunk 落库,ACL 过滤写在 KNN 查询内部(SQL 的 WHERE 子句),而不是取回后再过滤。
  • 把课程真实 Markdown 按标题层级做结构感知 chunking,并与第11章的固定窗口基线对比。
  • 向量路 + BM25 路并行召回,用 RRF 融合;相似度低于阈值时走第11章既有的拒答路径。
  • 用 20 题最小评估集(含 3 条无答案题)跑出 recall@k、引用正确率、无依据断言率三个确定性指标,并给出教学版 vs 实战版的量化对比。

公式与 shape

向量路仍用第11章的余弦相似度;sqlite-vec 对 L2 归一化向量返回 L2 距离,换算回余弦:

sim(q,d)=1qd222(q=d=1).

混合检索用 Reciprocal Rank Fusion 融合两路排名(不需要对齐两路的分数量纲):

score(d)=i{vector,BM25}1k+ranki(d),k=60.
对象shape约束
provider.embed(texts)(N, 512)L2 归一化;hash/bge/Gemini 三个 provider 签名相同
vec0 虚拟表embedding float[512]KNN 用 SQL 写;ACL 过滤是查询的一部分
chunk 记录row稳定 chunk_idsourcesectionacl(JSON 数组)
RRF 融合输入两路 chunk_id 排名只融合排名,不融合原始分数
拒答阈值标量本语料标定 0.45,见 Evidence;换模型/语料必须重新标定

从零实践

代码集中在 python/agent_core/rag_live.py(新文件,不改动第11章的 rag.py/embedding.py),组件少到一页读完:三个 embedding provider、chunk_markdownSqliteVecStorerrf_fuseLiveRagIndex。不引入 LangChain/LlamaIndex(框架留给第16章);Chroma 这类独立向量服务本课程不需要,嵌入式 sqlite-vec 足够且与"过滤先于排序"同构。OpenAI embedding 仅提及,不作为对照实现。

模块 1 · 从伪向量到真向量

HashEmbeddingProvider(第11章 hash 基线)、BgeEmbeddingProvider(本地 24M 参数、512 维、约 95MB、中文优化、CPU 毫秒级;国内可改用 ModelScope 下载)、GeminiEmbeddingProvidergemini-embedding-001,默认 3072 维、可截断;需 GEMINI_API_KEY,无 key 直接抛错而不是伪造输出)三者暴露同一个签名:embed(texts) -> (N, D) + embed_query(text)

  • 动手:跑 test_bge_synonym_queries_recall_consistently_where_hash_fails——同一个意思的两种问法("大模型的训练目标是什么" vs "LLM 的训练目标是什么"),bge 下 top-1 是同一 chunk,hash 向量下 top-1 落到只共享 token llm 的干扰句。
  • 验证标准:test_provider_interface_signature_is_identical_across_providers 断言三个 provider 签名一致,且 hash provider 输出与第11章的 hashed_embedding 逐位相同。

模块 2 · sqlite-vec 入库与 SQL 检索

建一张 vec0 虚拟表存向量、一张普通表存 chunk metadata;检索是一条 SQL:KNN 的 WHERE 里带 rowid IN (SELECT ... WHERE acl 可见)。已用探针验证 sqlite-vec 在 KNN 过程中应用该约束(k=2 时第二近的越权向量不会挤掉可见向量),所以"先过滤、再排序"在数据库内部成立,不是应用层的事后过滤。

  • 动手:跑 test_sqlite_vec_acl_filter_precedes_knn——低权限 principal(reader/public)的向量路与混合路 top-k 中都绝不出现 ops chunk,且 citation、source 可回链(断言风格复用 test_rag_acl.py)。
  • 验证标准:test_empty_acl_is_not_public_and_db_is_rebuildable——空 ACL 不等于公开;删掉 .db 文件后用源文档完全重建,检索结果逐项一致。

模块 3 · 结构感知 chunking

chunk_markdown 按标题层级切段(section 记录标题路径),段内按句子装箱:约 500 token(粗略估计:1 CJK 字 ≈ 1 token)、15% overlap(以下一句开头为界回带尾部整句),句子绝不从中间截断(单句超上限是文档化的例外,按字符预算硬切)。

  • 动手:跑 test_chunker_respects_token_limit_sections_and_sentence_boundaries——真实切分 docs/chapters/03-autograd.md+11-rag.md 得到 38 个 chunk(21+17)。
  • 验证标准:每个 chunk ≤ token 上限、section 非空、每个 chunk 以句末标点结尾;同一文本喂给第11章固定窗口基线则出现非句末结尾的 chunk(测试里并排断言,作为反例)。

模块 4 · 混合检索 + 阈值拒答

search_hybrid 并行取向量路与 BM25 路(BM25 复用第11章的 RagIndex,每个结构感知 chunk 作为一条"文档"),rrf_fuse(k=60,约 10 行)按排名融合。answer 用融合后 top 结果的向量相似度与阈值比较:低于阈值走与第11章相同的拒答路径(refused=True、无 citation、evidence 经转义渲染为数据而非指令)。

  • 动手:跑 test_hybrid_recall_covers_vector_only_blind_spots——含代码标识符/专有名词的问题,BM25 路把向量路漏掉的 gold chunk 捞回 top-5。
  • 验证标准:融合后 recall@5 不低于纯向量路(逐题断言 hybrid_ok >= vector_ok),且至少 1 题混合命中、纯向量漏掉;阈值 0.45 在本语料上把 3 条无答案题(最佳相似度 ≤ 0.381)与 17 条可答题(top-1 相似度 ≥ 0.482)分开。

模块 5 · 最小评估集与指标

20 条"问题 → 人工标注可接受证据短语"评估集(17 条可答 + 3 条无答案),跑在真实课程文档上,三个确定性指标:recall@k、引用正确率(被引用 chunk 含 gold 短语)、无依据断言率(答案 token 未出现在证据中的比例,抽取式答案的天然上界)。RAGAS 框架可作为选做对照,本课程不引入。

  • 动手:跑 test_eval_live_recall_beats_teaching_baselinetest_eval_citation_correctness_and_refusal_are_perfect
  • 验证标准(本次真实运行,详见 Evidence):实战版 recall@1 = 1.0、recall@5 = 1.0;教学版(同一文档+同一题集,第11章固定窗口 + BM25)recall@1 = 0.588、recall@5 = 0.706;引用正确率 1.0;无答案题拒答率 1.0;无依据断言率 0.0。

当前入口与测试:

bash
pip install -r requirements-agent.txt   # sqlite-vec + sentence-transformers(torch 已有)
PYTHONPATH=python python -m pytest python/tests/test_rag_live.py -q

故障注入与预期信号

注入预期失败信号修复后证据
把 ACL 过滤挪到 KNN 之后(先取 top-k 再在 Python 里筛)k=2 时越权向量挤掉可见向量,低权限 principal 看到 ops chunk 或候选数不足test_sqlite_vec_acl_filter_precedes_knn 中读者 top-k 只含 public-guide
embedding 写入前不做 L2 归一化sim = 1 - d²/2 公式失效,相似度可能越出 [-1, 1],阈值语义漂移三个 provider 输出范数断言为 1(接口测试)
固定窗口 chunking 切在句子中间chunk 以半个词结尾,引用回链无法定位到完整语义结构感知 chunker 每个 chunk 以句末标点结尾(模块 3 测试)
只用向量路召回含代码标识符/专有名词的问题 gold chunk 掉出 top-5RRF 融合后 BM25 路补齐(模块 4 测试)
拿掉相似度阈值无答案问题也返回"证据",拒答率跌破 100%3 条无答案题全部 refused=True(模块 5 测试)
无 API key 时 Gemini provider 静默降级到 hash对照实验悄悄变成伪向量,结论失真provider 直接抛 RuntimeErrortest_gemini_provider_refuses_to_fake_offline

论文与延伸

前端/Agent 迁移

向量库不是黑盒服务,而是一张可以 EXPLAIN 的表:前端可以把 RRF 的两路排名并排展示,让用户看到"为什么这个 chunk 被选中";Agent 应把"向量路认为相关的"和"词面路认为相关的"分开记 trace,阈值拒答是一等状态而不是异常。迁移到托管向量库时,ACL 前置这条纪律必须跟着走——先确认目标库的过滤是否在索引内部生效。

口述与自测(不看资料,5–10 分钟)

  • 按"chunk → embed → 入库 → ACL 内嵌 KNN → 两路召回 → RRF → 阈值/拒答 → citation"口述一条查询;解释为什么 RRF 只需要排名而不需要两路分数同量纲。
  • 解释 hash 向量为什么在"大模型 vs LLM"改写下必然失败、bge 为什么不会;说出本章阈值 0.45 的标定依据和它的适用边界。

实验与参考

动手实验

把第11章的三条纪律在真实向量栈上原样验证一遍:真实 embedding 下 ACL 仍然前置、引用仍然可回链、无证据仍然拒答,并量化"真向量 + 混合检索"相对教学基线的召回提升。

环境准备

bash
cd <仓库>
export PYTHONPATH="$PWD/python"
pip install -r requirements-agent.txt
# 首次运行会从 HF Hub 下载 BAAI/bge-small-zh-v1.5(约 95MB);
# 国内网络可改用 ModelScope 下载同一模型后指向本地路径。

命令与预期输出

bash
PYTHONPATH=python python -m pytest python/tests/test_rag_live.py -q
text
...........                                                              [ 100% ]
11 passed in 15.62s

判定信号:
  三个 embedding provider 接口签名一致(hash/bge/Gemini)
  低权限 principal 的 top-k 绝不出现 ops chunk(向量路 + 混合路)
  每个 chunk ≤ token 上限、section 非空、不以半句结尾
  RRF 融合结果与手算一致;混合 recall@5 ≥ 纯向量路
  20 题评估:引用正确率 1.0、无答案题拒答率 1.0、无依据断言率 0.0
  实战版 recall@5 (1.0) > 教学版 (0.706)

概念图

资源 / 成本 / 隐私

bge-small-zh-v1.5 本地下载约 95MB、CPU 可跑,gross cost 为 0;模型权重只进本地 HF 缓存,不入库。Gemini 对照有免费层但会把 chunk 文本发给第三方 API,默认不启用;私人资料不得进入索引(空 ACL 不等于公开)。.db 文件是派生产物,可随时删除重建。

Evidence

仓库当前机器证据(只读快照)

evidence/12-rag-live-v1.json 是当前 checkout 的脱敏机器运行记录:20 题评估跑在真实 bge-small-zh-v1.5 上,实战版 recall@1/recall@5 = 1.0/1.0,教学版 0.588/0.706(分母 17),引用正确率 1.0,无答案题拒答率 1.0(分母 3),无依据断言率 0.0。它只覆盖该 JSON 记录的命令与指标:评估集只有 20 题、语料只有 2 篇课程文档(38 个 chunk),recall 1.0 不构成分布外检索质量的声明;阈值 0.45 只对本语料+本模型有效;Gemini provider 未实测。它不是学习者提交,也不能推出学习者已完成本章。

学习者提交模板(待填写,不是当前机器证据)

复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actualartifacts 尤其不能被当作已运行或已通过。artifacts 必须替换为本次提交中真实存在的仓库相对路径。

yaml
schema: learn-llm.evidence.v1
module: 12-rag-live
commit: <learner-commit-sha>
verified_at: <iso-date>
environment: <sanitized-python-device>
seed: 9
commands:
  - PYTHONPATH=python python -m pytest python/tests/test_rag_live.py -q
  - PYTHONPATH=python python <learner-live-rag-eval>
metrics:
  - name: live_recall_at_5
    expected: <versioned-threshold>
    actual: <recorded-value>
  - name: citation_correctness
    expected: <versioned-threshold>
    actual: <recorded-value>
  - name: unanswerable_refusal_rate
    expected: <versioned-threshold>
    actual: <recorded-value>
  - name: unsupported_claim_rate
    expected: <versioned-threshold>
    actual: <recorded-value>
artifacts:
  - <learner-repo-relative-artifact-path>
cost:
  gross_usd: 0
  credit_usd: 0
licenses:
  - source: <source>
    version: <version>
    license: <license>
    attribution: <attribution>
    redistribution: <redistribution>
known_failures:
  - <sanitized-failure-or-none>

模型未下载、阈值未标定或评估集缺无答案题时,本章保持 gate;hash provider 的离线绿测不宣称语义检索质量。

下一步

→ 第13章:把本章的知识库问答 demo 接上真实的生成模型,让 answer 从"渲染证据"变成"基于证据生成",引用正确率与无依据断言率的口径也要随之从 token 重叠升级为逐主张核验。

私有学习站 · 原理从零构建 · 勿提交个人隐私或密钥