RAG 链路入门

从「问一句话」到「翻出 5 张卡片」,用 notes-rag 的真实运行数据讲一遍。看不懂概念的时候,回来找具体那一行输出就行。

状态 · 现行 2026-11-11 快照 本文数字只用于把概念讲具体;唯一权威是 learning/实验台账.md,重跑后以台账为准。

速览 · 3 分钟

整个项目就是一场开卷考试。637 篇笔记,大模型一次读不完也背不住,所以不让它背:每次提问先翻出最相关的几页,摊在它面前让它照着答。RAG = Retrieval(翻资料)→ Augmented(贴在问题前)→ Generation(写答案)。

  1. 准备一次:ingest.py 把 637 篇笔记做成 6696 张卡片 + 一张索引表。改了切分或换模型才重跑。
  2. 每次提问:cli.py 把问题也变成向量,跟 6696 张卡片比相似度,取前 5 张。
  3. 只有前 3 张真正塞给大模型(max_hits=3),第 4、5 张只是打印给你看。
  4. 考试:evaluate.py 跟「回答」无关,只是把 50 道题批量丢进检索,然后对答案路径。
  5. 最该先学会的不是写代码,是判断数字能不能信:改一处,前后各量一次,看分数涨没涨。

左上是目录,点标题跳转;右上角可切深色;代码块右上角有复制按钮;手机上目录会自动横排。整份文件不联网、不依赖任何 CDN,双击就能开。

1 一句话理解 RAG

你有一柜子自己的笔记。现在问:「MySQL 建联合索引的时候,等值条件和范围条件谁该放前面」。

如果没有 RAG,模型只能凭训练时记住的通用知识答,答不出「你自己那篇笔记里怎么写的」。RAG 的做法笨但可靠:

  1. 把每篇笔记切成小卡片,每张卡片算出一个「意思向量」(1024 个数字)。
  2. 把问题也算出同样格式的向量,跟所有卡片比方向,翻出最像的 5 张。
  3. 把这 5 张里的前 3 张原文,连同问题一起发给大模型,让它只许照着资料答。

关键直觉:检索层不产生答案,它只决定「让模型看哪几页」。所以检索做对了,答案基本就有了;检索翻错页,模型再强也只能照着错资料编。

项目里 generator.py 的系统提示词就是这么写的:「只根据 <资料> 回答,资料里没有就直接说没找到,每句话标注来源编号」。

2 三条命令 = 三条不同的路

初学者最容易混的就是这个:它们根本不是同一条流程,跑的时机也完全不同。

什么时候命令实际干什么本机耗时
准备一次
改切分 / 换模型才重跑
scripts/ingest.py扫语料 → 切块 → 逐块向量化 → 写 data/index/ 三个文件1h29m
CPU 8 线程
每次提问
问一句走一遍
python -m notes_rag.cli加载索引 → 检索 5 张 → 拼 prompt → 调 LLM 生成2.0s + 355ms
考试
改完代码量一次
scripts/evaluate.py50 道题批量跑检索,看前 5 张里有没有标准答案的路径约 20s

别把 evaluate.py 当成「问 50 次」。它完全不调大模型,只走检索和对答案这两步。所以即使 LLM_API_KEY 是空的,评测照样能跑出完整数字。

还有一条本地辅助命令 scripts/serve_ui.py + web/index.html:一个只读的可视化页面,用来肉眼看检索命中的卡片。它不建索引、不调 LLM,看的同样是检索层。

3 六步数据流

点下面的步骤,一次只看一步。这六步根本不在同一条路上:前两步是「准备一次」(ingest.py,建好就不动),中间三步是「每次提问」(cli.py,问一句走一遍),最后一步是「考试时」(evaluate.py,只跟检索对答案)。每步都给了输入 → 输出的真实数据和对应代码。

准备一次 · ingest.py

1 · 把一篇笔记拆成小卡片

大模型一次读不下 637 篇笔记,所以按标题把每篇切成不超过 512 字的小块。每块都带一张「我来自哪篇」的标签,叫 source,后面认亲全靠它。

输入
note/docs/ 下 637 篇 md
纯文本约 3 MB
(目录 113 MB 里其余是图片,不参与检索)

CORPUS_DIR =
  D:/_work/cqlql-notes/docs
输出 · 实跑结果
heading / size=512 / overlap=51
637 篇 → 6696 块,平均 367 字/块
碎片块(<50 字)166 块 · 耗时 0.4s

meta.jsonl 第一行:
{ "id": "ai/AI实时语音.md#0-598186d4",
  "source": "ai/AI实时语音.md",
  "heading": "# WebRTC API",
  "text": "# WebRTC API\n..." }

对应代码 src/notes_rag/chunker.py · chunk_file();参数在 .env,改参数不改代码。注意:语料是 note 分支的 note/docs/,不是 rag 分支的 docs/;文档里的「632 篇」是旧数,笔记在长。source 是相对语料目录的路径,改切分别改这个格式。

准备一次 · ingest.py

2 · 给每张卡片编一组「意思数字」并存盘

bge-m3 把每块文字变成 1024 个数字。意思相近的文字,数字也相近,所以后面能靠算距离找资料。数字连同原文一起存进 data/index/,这一步只跑一次。

输入
6696 块文字
+ BAAI/bge-m3(本地缓存,离线加载)
模型权重常驻约 2.2 GB
输出 · 磁盘上三个文件
data/index/
  vectors.npy    27 MB (6696,1024) float32
  meta.jsonl     6696 行 {id,text,source,heading}
  manifest.json  heading/512/51 · bge-m3 · dim 1024

向量已归一化:第一行范数 = 1.0
→ 点积就是余弦,不用再除模长

为什么要有 manifest:改了 CHUNK_STRATEGY / CHUNK_SIZE / 切分代码 / embedding 模型,必须重跑 ingest.py。否则 evaluate.py 量的是旧索引 —— 数字照常打印、完全不报错,但结论全错。cli.py 只在 numpy 后端比对 chunk_size 和 chunk_strategy 两项,别指望它兜底。

对应代码 embedder.py + scripts/ingest.py。本机没有 GPU,CPU 8 线程跑了 5361 秒(1h29m);切分只要 0.4 秒,瓶颈全在向量化。bge-m3 不需要给 query 加指令前缀,换模型先看模型卡。

每次提问 · cli.py

3 · 把问题也变成同一组「意思数字」

问题过的是同一个 bge-m3 模型,出来同样是 1024 维、同样归一化。只有格式一样,才能跟 6696 张卡片算相似度。加载模型 + 索引实测 2.0 秒。

输入
MySQL 建联合索引的时候,
等值条件和范围条件谁该放前面

(一句自然语言,不加任何指令前缀)
输出
query 向量 · shape (1024,) · float32
范数 1.0,与库里的向量同一口径

接下来只需一次矩阵点积

对应代码 retriever.search() → embedder.encode()。这一步是「每次提问」的起点,跟前两步的「准备一次」隔了整整一次建索引,别混成一条流水线。

每次提问 · cli.py

4 · 跟 6696 张卡片比相似度,找回 5 张

一次矩阵点积,从大到小排,取前 5 行,再回查 meta.jsonl 拼出带原文的结果。「检索」这个词落到代码里,就是这个动作。

输入
query 向量 (1024,)
× vectors.npy (6696, 1024)
TOP_K = 5
输出 · 实跑结果
0.7000 / 0.6186 / 0.6095
0.5868 / 0.5831
检索 355ms(50 题平均 188ms)

第 1 张原文就含:
「等值在前,范围在后」

对应代码 retriever.py / store.py。5 张里 4 张来自同一篇(heading 按标题切,所以「5 条结果」≠「5 篇笔记」);第 3~5 张分数只低一点却明显偏题 —— 分数高不等于内容对。完整 5 张卡片见 §4。

每次提问 · cli.py

5 · 把资料贴在问题前面,让模型照着答

build_context() 把命中卡片拼成带编号的资料块,连同问题发给大模型。系统提示词要求「只根据 <资料> 答、没找到就说没找到、每句标来源编号」。

输入
前 3 张卡片(实测 555 字)
+ 问题 + system prompt

格式:
[1] 来源:路径(标题)
    卡片原文
输出
带 [1][2][3] 来源编号的中文回答
先给结论,再给细节
资料里没有就说「笔记里没找到」

最容易记混的一点:--top-k 5 召回 5 条,但 answer() 写死 max_hits=3,只有前 3 张进 prompt。召回 10 条也一样只有前 3 条进去。第 4、5 张只打印给你看,模型没看到。

对应代码 generator.py · build_context() / answer(max_hits=3)。当前状态:本机 .env 里 LLM_API_KEY= 为空,这一步会抛 Missing credentials —— 不是代码坏,填 key 即通;前四步和评测都不依赖它。

考试时 · evaluate.py

6 · 50 道题批量丢进前四步,然后对答案

这一步跟「回答」没关系:完全不调大模型,只把每道题跑一次检索,看返回的 5 个 source 里有没有 expected 那个路径,有就算命中。判定核心只有 3 行代码。

输入 · 考卷
data/eval/queries.jsonl · 50 行
{ "query": "你会怎么问的一句话",
  "expected": ["相对语料目录的路径"] }

query 按口语写,避开标题术语
输出 · 2026-11-11 快照
命中        45 / 50
recall@5    0.900
MRR         0.801
检索耗时    平均 188ms · P95 230ms
未命中      5 条(清单见 §5)

全程约 20 秒,不调 LLM

对应代码 scripts/evaluate.py。它没量什么:答案对不对、有没有编、资料不足时该拒答有没有拒答 —— 生成层评测尚未实现,不许拿 recall@5 当「答案正确率」。完整拆解见 §5。

4 一次真实提问

问题:「MySQL 建联合索引的时候,等值条件和范围条件谁该放前面」。下面 5 张卡片是终端里打印的原值,不是示意图。

10.7000进 prompt
后端/数据库/mysql/04_索引设计.md · ## 5. 组合索引核心规则
原文:最左前缀原则 · 等值在前,范围在后 · ORDER BY 顺序要和索引一致
20.6186进 prompt
后端/数据库/mysql/04_索引设计.md · ## 7. 建索引的方式 / ### 组合索引
原文:KEY idx_user_status (user_id, status) · 最左前缀 (a,b,c)
30.6095进 prompt
后端/数据库/mysql/06_面试与踩坑.md · ## 索引 ≠ 约束
原文:PRIMARY / UNIQUE 索引+约束 · FOREIGN KEY 不自动建索引
40.5868不进 prompt
后端/数据库/mysql/04_索引设计.md · ## 3. 什么时候该建索引
原文:WHERE / JOIN / ORDER BY 出现的字段 · 区分度高的字段
50.5831不进 prompt
后端/数据库/mysql/04_索引设计.md · ## 2. 索引 ≠ 约束(必记)
原文:PRIMARY / UNIQUE 索引+约束 · 面试 & 生产踩坑点

指着卡片看四件事

  1. 第 1 张直接命中。原文就写着「等值在前,范围在后」—— 答案第一张就翻到了。这就是检索的全部含义:不靠模型背书,靠翻到对的那一页。
  2. 0.7000 是相似度分数,条长就是这个数。算法只是「问题向量 · 6696 个块向量,从大到小排」,没有玄学。
  3. 5 张里 4 张来自同一篇笔记。因为 heading 策略把一篇 md 按标题切成了多块,所以「5 条结果」≠「5 篇笔记」。
  4. 第 3、4、5 张分数只低一点(0.61 / 0.59 / 0.58),内容却明显偏题(索引≠约束、什么时候建索引)。分数高不等于内容对 —— 这句以后会反复用到。

最短的冒烟测试就是 cli.py "问题" --show-chunks:肉眼看一眼命中是不是真笔记,别是 front matter 碎片或表格残片。这一步比读十篇教程管用。

5 评估到底是什么

把上一节的抽象话落到地上:「评估」就是一份考卷 + 对答案。

考卷长什么样

data/eval/queries.jsonl 一行一题,共 50 条:

{"query": "实时音频为什么不用长连接那套,抖动和丢包靠什么兜",
 "expected": ["ai/AI实时语音.md"]}

query 要按「你会怎么问」写,不要照抄文档标题;expected 是相对语料目录的路径,答案分散在多篇时可以写多个。

对答案的核心只有三行

sources = [h["source"] for h in hits]        # 检索返回的 5 个路径
rank = next((i+1 for i,s in enumerate(sources) if s in expected), None)
hit_count += 1 if rank else 0                 # 有就算命中
# recall@5 = 命中数 / 50      MRR = 各题 1/rank 的平均值

就这么多。它只查「有没有翻到那篇笔记」,完全没看最后那句回答对不对。

本次 baseline(2026-11-11 快照)

指标值人话
命中45 / 5050 道题里 45 道在前 5 张卡片中翻到了标准答案那篇
recall@50.90045 ÷ 50
MRR0.801正确答案平均排第几;排第 1 得 1 分,第 2 得 0.5 分
检索耗时188ms / P95 230ms平均 / 95% 的题比这快

它没量什么:答案对不对、有没有编、资料不足时该拒答有没有拒答 —— 生成层评测还没实现。不许拿 recall@5 当「答案正确率」写进简历或汇报。

5 道没翻到的题,比分数更值得看

祖先组件要给很深的后代传值,又不想每层都写 props,Vue 里怎么办
期望 前端/vue/01_Vue3/Provide 和 Inject.md · 实际翻出 React 学习.md、Vue2 插槽.md
跳详情页要带个手机号,但不希望它出现在地址栏里
期望 前端/vue/vue-router/路由传参.md · 实际翻出 dom-overview.md、jwt token.md、wechat/登录.md
想把一个方法当变量传来传去,还要能声明「函数的类型」
期望 后端/dotnet/csharp/委托-函数类型.md · 实际翻出 TS 实战技巧.md、Vue3 typescript.md
自己写的服务单元该放哪个目录,启动前还要先切到工作目录
期望 linux/进程与服务/systemctl.md · 实际翻出三篇「目录规范 / 部署布局」
实时音频为什么不用长连接那套,抖动和丢包靠什么兜
期望 ai/AI实时语音.md · 实际翻出三篇面试助手的声纹 / 心跳方案

共同特征很清楚:用口语提问,答案文件却叫术语名。语义上「像」,但不是标准答案那篇。这正是「query 要按你会怎么问写」为什么值那一小时 —— 也是日后做混合检索 / rerank 的真实动机,不是凭感觉加模块。

「改一处,前后各量一次」现在有具体含义了

比如让 AI 只把那 166 个碎片块丢掉:改之前是 0.900,改完 重跑 ingest.py 重建索引,再跑一次 evaluate.py,看变成多少。这个数字就是判断依据,而不是「感觉好像更干净了」。

四条对照纪律(做实验前默念一遍)
  • 同一份 queries.jsonl、同后端、同 CHUNK_SIZE,中间不动别的代码。
  • 改切分 / 换模型 → 必须重跑索引,否则量的是旧数据。
  • 换评估集 = 换标准,两种策略都要用新集各量一次,旧数字作废。
  • numpy 和 pg 后端的 recall@5 必须用同一份考卷跑,否则比的是两套标准。

6 四个静默的坑

这几个都不会抛异常、lint 照过、数字照样打印,但结论全错。只有懂口径的人能抓出来 —— 这也是「先学验收」的真正原因。

① 改了切分,没重建索引

meta.jsonl / vectors.npy 还是旧的,evaluate.py 读它算出来的分数看起来完全正常。cli.py 的告警只比对 chunk_size 和 chunk_strategy 两项,改了 chunker.py 本身不会提醒。

② 考卷太小,天花板效应

项目里真的发生过:12 条试水集上曾判定 heading 策略掉点,补齐到 50 条后结论直接反转,heading 反超并被拍板设为默认。差的就是那么一两条命中。(具体数字与归因见 learning/实验台账.md §0.5) 换评估集 = 换标准,旧数字作废。

③ 把 recall@5 当成答案正确率

它只查路径命中。生成层的「对不对 / 有没有编 / 该拒答时拒答」尚未实现,AGENTS.md §3 明确禁止把 recall 写成答案正确率。

④ 环境坑:这三个会让你以为「代码坏了」

现象真实原因怎么办
502 Bad Gateway 卡在模型加载本机设了 HTTP 代理,HuggingFace 版本校验被挡;模型缓存其实是完整的命令加 HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1,强制离线读缓存
Missing credentials.env 里 LLM_API_KEY= 为空填 key;检索与评测不需要它,报错出现在卡片打印之后
GPU 莫名消失uv sync / uv run 会把 .venv 同步回 lock 里的 CPU 版 torch跑实验一律 uv run --no-sync,或直接用 .venv/Scripts/python.exe

本机当前实际是 CPU 版 torch,没有 NVIDIA GPU;AGENTS.md §4 记的 CUDA 加速不在这台环境里。CPU/GPU 向量逐块余弦 1.000000、recall/MRR 一致,所以跨设备建索引不破坏可比性 —— 但耗时不可直接比,看 P95,别把 CUDA 冷启动写进指标。

7 命令速查

先 cd 到 D:/_work/cqlql-notes-branches/cqlql-notes.rag。所有命令都带离线变量 —— 这台机器上不带就会挂在 HuggingFace 校验上。

建索引(只在改切分 / 换模型后)

HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 HF_HUB_DISABLE_TELEMETRY=1 \
OMP_NUM_THREADS=8 MKL_NUM_THREADS=8 OPENBLAS_NUM_THREADS=8 \
PYTHONIOENCODING=utf-8 \
uv run --no-sync python scripts/ingest.py

问一句,看命中的卡片

HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 OMP_NUM_THREADS=2 \
PYTHONIOENCODING=utf-8 \
uv run --no-sync python -m notes_rag.cli "你的问题" --show-chunks

没填 LLM_API_KEY 时,卡片会先打印出来,之后才报 Missing credentials —— 不影响看检索结果。想看完整答案先填 key。

考试(改完代码量一次)

HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 OMP_NUM_THREADS=2 \
PYTHONIOENCODING=utf-8 \
uv run --no-sync python scripts/evaluate.py

每个变量为什么

HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1
强制离线读本地模型缓存,一次网络请求都不发。绕开代理导致的 502。
OMP_NUM_THREADS 等
限制计算线程数。想一边跑一边干别的事就调小(2 = 很省但慢,8 = 约 1.5 小时)。
PYTHONIOENCODING=utf-8
Windows 下中文输出不乱码。
--no-sync
阻止 uv 把 .venv 同步回 lock 里的 CPU 版 torch。
--show-chunks
打印命中卡片的原文与分数,最短的冒烟测试。
--top-k 10
改召回条数。注意它和 max_hits=3 是两回事。

8 名词速查

词人话在项目里对应
Chunk / 块把一篇笔记切成的小卡片meta.jsonl 的一行;字段 id / source / heading / text
Embedding / 向量把一段文字变成 1024 个数字embedder.py + vectors.npy
余弦相似度两个向量方向有多像,越接近 1 越像向量已归一化,所以点积就是余弦
score那张卡片和问题有多像检索结果里的 score,如 0.7000
top-k翻几张卡片.env 的 TOP_K=5 / --top-k
max_hits真正塞进 prompt 几张generator.answer() 里写死 3
recall@550 题里前 5 张包含正确答案路径的比例evaluate.py;检索层指标
MRR正确答案平均排第几(取倒数)排第 1 得 1 分,第 2 得 0.5 分
manifest.json索引的出生证明:用什么参数建的改切分 / 换模型后必须重建
CORPUS_DIR笔记语料在哪note/docs/,不是 rag 分支的 docs/
queries.jsonl考卷,一行一题data/eval/;schema 权威在 evaluate.py docstring
rerank初筛后再精排一次本项目尚未实现,属后续增量

9 该按什么顺序学

结论:先弄懂「验收」,不是 Python 语法,也不是 transformer 原理。全 AI 辅助下「写得出」不再是瓶颈,「验得出」才是 —— 实现可以外包,判断这个结论成立不成立没人替你签字。

能落地的学习顺序

  1. 先能指出「这次回答用的是哪几张卡片」。换 5 句自己的问题跑 --show-chunks,看答案在第几张、来自哪篇笔记、有没有翻错页。这一步不需要懂任何数学。
  2. 再能回答「这个数字量的是哪一层」。跑一次 evaluate.py,能说出 0.900 是怎么算出来的、以及它没量什么。
  3. 然后能指挥 AI 干活。把需求翻译成「只改一处、固定项不动、前后各量一次」,由你判断数字能不能信,再决定合不合。
  4. 最后才碰优化。rerank、pgvector / HNSW、生成层评测、拒答率 —— 每一项都应该先有 baseline 数字再动。

明确不该先碰的

transformer 内部结构、HNSW 数学推导、PagedAttention 细节、训练 / 微调。这些按需查就行;learning/能力清单.md 里「没做过也能答」那一类已经排过序了。

只学到「能读懂 + 敢质疑」就停

Python 基本语法、向量与余弦、top-k、search() 的返回形状。够 review diff、看懂报错、定位 AI 跑偏在哪一层即可,不用练到能默写。

为什么「静默错误」是这套学习顺序的核心理由

AI 写的代码里,最危险的不是抛异常的那类 —— 那种你一眼就能发现。是不报错、lint 照过、数字照常打印、但结论全错的那类:量了旧索引、考卷太小导致结论反转、把检索指标当答案正确率。第 6 节四个坑全是这种。它们没有 stack trace,只有懂口径的人能抓出来。

所以 AGENTS.md 通篇几乎不讲「怎么实现」,讲的全是评估集、指标口径、单变量对照、哪里会静默出错。最硬那条红线「每个改动都要有前后数字」就是这块能力的浓缩。