Appearance
第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 距离,换算回余弦:
混合检索用 Reciprocal Rank Fusion 融合两路排名(不需要对齐两路的分数量纲):
| 对象 | shape | 约束 |
|---|---|---|
provider.embed(texts) | (N, 512) | L2 归一化;hash/bge/Gemini 三个 provider 签名相同 |
| vec0 虚拟表 | embedding float[512] | KNN 用 SQL 写;ACL 过滤是查询的一部分 |
| chunk 记录 | row | 稳定 chunk_id、source、section、acl(JSON 数组) |
| RRF 融合输入 | 两路 chunk_id 排名 | 只融合排名,不融合原始分数 |
| 拒答阈值 | 标量 | 本语料标定 0.45,见 Evidence;换模型/语料必须重新标定 |
从零实践
代码集中在 python/agent_core/rag_live.py(新文件,不改动第11章的 rag.py/embedding.py),组件少到一页读完:三个 embedding provider、chunk_markdown、SqliteVecStore、rrf_fuse、LiveRagIndex。不引入 LangChain/LlamaIndex(框架留给第16章);Chroma 这类独立向量服务本课程不需要,嵌入式 sqlite-vec 足够且与"过滤先于排序"同构。OpenAI embedding 仅提及,不作为对照实现。
模块 1 · 从伪向量到真向量
HashEmbeddingProvider(第11章 hash 基线)、BgeEmbeddingProvider(本地 24M 参数、512 维、约 95MB、中文优化、CPU 毫秒级;国内可改用 ModelScope 下载)、GeminiEmbeddingProvider(gemini-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 落到只共享 tokenllm的干扰句。 - 验证标准:
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 中都绝不出现opschunk,且 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_baseline与test_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-5 | RRF 融合后 BM25 路补齐(模块 4 测试) |
| 拿掉相似度阈值 | 无答案问题也返回"证据",拒答率跌破 100% | 3 条无答案题全部 refused=True(模块 5 测试) |
| 无 API key 时 Gemini provider 静默降级到 hash | 对照实验悄悄变成伪向量,结论失真 | provider 直接抛 RuntimeError(test_gemini_provider_refuses_to_fake_offline) |
论文与延伸
- Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(Lewis 等,2020)
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods(Cormack 等,2009)
- BGE: BAAI General Embedding(模型卡);sqlite-vec。
前端/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 -qtext
........... [ 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 未实测。它不是学习者提交,也不能推出学习者已完成本章。
学习者提交模板(待填写,不是当前机器证据)
复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actual 和 artifacts 尤其不能被当作已运行或已通过。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 重叠升级为逐主张核验。