RAG 入门:用向量库和嵌入模型搭最小检索链路
最小 RAG 最容易翻车的地方,不是向量库选得对不对,而是离线入库和在线检索这两段用了不一致的假设。把链路拆开、把不变量钉死,比先纠结用哪个库更有价值。
先把链路拆成两段,明确哪些东西必须一致
离线阶段:文档解析 → 分块 → 调嵌入接口 → 写入索引(向量 + 原文 + 元数据)。
在线阶段:用户问题 → 调嵌入接口 → 相似度检索取 top-k → 组装上下文 → 调生成模型。
真正的不变量只有三条:
- 离线与在线必须使用同一个嵌入模型、同一版本、同一套输入预处理;
- 检索时使用的相似度度量,要和入库时的向量处理方式对应;
- 分块参数在入库后保持不变,否则存量向量与新写入向量不在同一套切分逻辑上。
从工程角度看,一个很省事的做法是:在索引里存一份“配置指纹”,记录嵌入模型的标识、向量维度、分块参数、距离度量。在线检索时先校验这份指纹,能在排障阶段挡掉大量“结果莫名其妙”的问题。
嵌入调用需要先确定的几件事
调用嵌入接口看着简单,但有几个点会直接决定后面能不能查准。
维度。 维度决定索引结构和存储占用,换模型基本等于重建索引,所以模型选择要在建索引之前定下来,而不是先建好再换。
批量与限流。 一次请求能塞多少条文本、总 token 上限是多少,不同实现差异很大。按条数估算容易踩坑,按 token 数估算更稳。批量返回的顺序必须和输入顺序严格对齐——向量与文本错位是“检索不准”里最隐蔽也最常见的一类。
超长文本的处理。 超出长度限制时是被截断还是直接报错,取决于具体实现,必须实测,不能按经验假设。如果是静默截断,长块的尾部信息等于从未进入向量。
查询与文档是否要用不同输入形式。 部分嵌入模型对“查询”和“被检索文本”区分输入形式(例如需要加不同的任务前缀)。这一点不能靠经验统一处理,必须以所选模型的官方文档为准。如果模型要求加前缀而调用方没加,向量分布会整体偏移。
归一化与度量。 如果入库前自己做了 L2 归一化,那么余弦相似度和内积的排序结果一致;不做归一化时两者排序可能不同。选度量时要把这层关系一起考虑,而不是分开决定。
调用结构大致是这样(示意,具体参数名与上限以官方文档为准):
for chunk in chunks:
vec = embed_batch([chunk.text]) # 失败重试、顺序对齐
index.add(
id=chunk.id,
vector=vec,
payload={
"text": chunk.text,
"source": chunk.source,
"heading_path": chunk.heading_path,
},
)
分块:没有最优值,只有取舍
常见做法有三类:定长切分加重叠、按文档结构切、语义切分。
定长切分实现最简单,但会把表格、代码块、编号列表从中间劈开,切完的块在语义上不完整。按结构切分(标题层级、段落边界、代码块边界)通常性价比最高。语义切分成本高,入门阶段可以先不上。
块的大小是一组对冲:块太大,主题被稀释,相似度反而下降;块太小,单块信息不足,模型拿到也回答不了。工程上常见的做法是从中等长度起步,配合一定比例的重叠,避免答案正好落在切点上,然后用评估集回头调,而不是一开始就找“最优参数”。
每块建议带上元数据:来源文件名、标题路径、位置或页码。这些字段在检索阶段可以用于过滤(比如只在某个目录内检索),在生成阶段可以用于标注引用来源。
向量检索:先想清楚什么时候不需要它
如果块的数量只有几百到几千,直接在内存里做全量相似度计算并排序就够了,延迟和召回都能接受。只有当规模明显上来、暴力排序成为瓶颈时,引入专门的向量索引才有必要。
引入之后,选型要看的不是“谁更快”,而是这几个具体能力:
- 支持的距离度量,是否和你的向量处理方式匹配;
- 元数据过滤能力,过滤与向量检索的执行顺序会直接影响结果,需要按该库的语义确认;
- 批量写入、更新与删除,文档迭代时避免整库重建;
- 持久化方式,进程重启后索引是否还在。
如果使用近似检索,一般会存在一组在召回率和延迟之间做取舍的参数,调大通常更准也更慢。是否需要调,取决于你对召回的容忍度,而不是默认值好不好看。
top-k 要和上下文预算一起定
top-k 不是一个孤立参数。上下文预算大致等于:每块 token 数 × k + 提示模板 + 用户问题 + 预留的输出空间。
k 调大的代价不只是慢:无关片段会把正确答案埋在中间,生成模型更容易抓错重点。常见的调节手段有三种:设相似度分数阈值做准入、限制单篇文档最多贡献 n 个块、相邻块合并去重。
这里需要明确一点:相似度高只代表“语义接近”,不代表“对回答问题有用”。它是排序信号,不适合作为唯一的质量判断标准。
把检索结果拼进 Prompt
结构上建议保持三层:系统指令、带编号的检索片段、用户问题。
系统指令里至少要写清楚三件事:只依据给定片段作答;片段不足以回答时明确说明资料中没有;如果给出了回答,标注引用的是哪几号片段。
系统:你是……。只能依据下面的片段回答。
片段不足以回答时,直接说明“资料中没有相关内容”。
片段:
[1](来源:xxx)……
[2](来源:yyy)……
问题:……
一个容易被忽略的点:检索回来的片段是数据,不是指令。文档正文里如果出现类似“忽略以上指令”的内容,不应该被模型当作指令执行。把片段用明确的分隔符或编号框起来,并在系统指令里声明这一点,是成本最低的缓解方式。
当拼接结果超出上下文长度时,需要明确的截断策略:按分数截断、对片段做压缩后再拼,或者减少 k。不要依赖模型端的静默截断,那会让最关键的片段先被丢掉。
一套可以照着跑的验证顺序
RAG 的问题经常混在一起,分不清是召回不行还是生成不行。按下面的顺序分开测,定位会快很多。
- 只测检索。 准备 20 到 50 条问题,人工标注每条问题应该命中哪几个块,看这些块有没有出现在 top-k 里。检索不过关,换生成模型没有意义。
- 只测生成。 把人工确认过的正确片段直接喂给模型,看回答质量和引用标注是否正确。这一步过不了,问题在提示模板或模型,不在检索。
- 端到端对比。 跑完整链路,和上一步的结果对比,差距就是检索带来的损失。
- 固定变量。 记录嵌入模型标识、分块参数、k、阈值。每次只改一项,否则调好了也不知道是哪一项起的作用。
常见的失败模式
- 离线用了 A 模型,在线换了 B 模型,或者查询侧漏掉了任务前缀;
- 批量嵌入返回顺序与文本顺序错位;
- 表格、代码块被切断后语义丢失,向量里只剩半句话;
- 只调 k 不调分块,反复在同一批坏块上做排序;
- 文档更新后没有重新嵌入,检索到的是旧内容;
- 把相似度分数当质量分,阈值定得过松或过紧。
最小链路里的“最小”,指的是组件数量少,不是配置项少。把嵌入模型、分块参数、相似度度量这三项固定下来并写进配置,后续无论换向量库还是换模型,都有一条可以回退的基线。