64.6 向量检索与 RAG
一句话
RAG 的效果不是「向量库选得好」,而是检索到的上下文是否正确、够用、可引用:召回决定上限,重排决定精度,上下文组装决定模型能不能用上;向量索引只是在召回率、延迟、内存之间选一个工作点。
📚 参考:Hello-Agents 第 8 章《记忆与检索》对 RAG 与记忆系统的拆解。
RAG 的六段流水线
| 段 | 做什么 | 失败表现 |
|---|---|---|
| 切分(chunking) | 把文档切成可检索的块 | 块太大噪声多,太小语义不完整 |
| 嵌入(embedding) | 文本 → 向量 | 模型与业务语言不匹配,召回差 |
| 索引(index) | 向量写入 ANN 索引 | 召回率低、更新延迟、内存爆炸 |
| 召回(retrieve) | 取 top-k 候选 | 该有的没召回,后面全错 |
| 重排(rerank) | 交叉编码器/LLM 精排 | top-k 里相关的不在前面 |
| 组装(prompt) | 拼上下文 + 指令 + 引用 | 超上下文、丢引用、模型忽略证据 |
🔑 六段里最容易被低估的是切分与组装:切分决定「一段话是否自包含」,组装决定「模型看到的证据是否完整、是否知道来源」。换一个更强的 embedding 模型,往往救不了切分错误。
向量索引:四种工作点
| 索引 | 召回 | 延迟 | 内存 | 更新 | 适用 |
|---|---|---|---|---|---|
| 暴力(flat) | 100% | 高(O(N)) | 原始向量 | 实时 | 小数据、评测基线 |
| IVF | 可调(nprobe) | 中 | 原始向量 + 聚类 | 批量重建 | 百万级、内存充足 |
| HNSW | 高 | 低 | 图结构,内存大 | 增量插入 | 低延迟、在线检索 |
| PQ / IVF-PQ | 有损 | 低 | 压缩,内存小 | 批量 | 亿级、内存受限 |
🔑 「召回率 100%」只对暴力成立;ANN 的召回率是相对暴力 top-k 的比率。上线前必须用真实查询集测 recall@k,而不是相信「用了 HNSW 就准」。
代码:IVF 的 nprobe 怎么换 recall
5000 条 32 维向量、50 个聚类、top-10;nprobe 越大,扫描越多、召回越高。
package main
import (
"fmt"
"math"
"math/rand"
"sort"
)
const (
nVec = 5000
nQuery = 100
dim = 32
nList = 50
topK = 10
)
type vec []float64
func dist(a, b vec) float64 {
var s float64
for i := range a {
d := a[i] - b[i]
s += d * d
}
return s
}
func topKIDs(q vec, data []vec, ids []int, k int) []int {
type pair struct {
id int
d float64
}
ps := make([]pair, len(ids))
for i, id := range ids {
ps[i] = pair{id, dist(q, data[id])}
}
sort.Slice(ps, func(i, j int) bool { return ps[i].d < ps[j].d })
out := make([]int, 0, k)
for i := 0; i < k && i < len(ps); i++ {
out = append(out, ps[i].id)
}
return out
}
func main() {
r := rand.New(rand.NewSource(7))
centers := make([]vec, nList)
for i := range centers {
centers[i] = make(vec, dim)
for d := 0; d < dim; d++ {
centers[i][d] = r.NormFloat64()
}
}
data := make([]vec, nVec)
lists := make([][]int, nList)
for i := 0; i < nVec; i++ {
c := centers[r.Intn(nList)]
v := make(vec, dim)
for d := 0; d < dim; d++ {
v[d] = c[d] + r.NormFloat64()*1.4
}
data[i] = v
best, bestD := 0, math.Inf(1)
for j, c := range centers {
if dd := dist(v, c); dd < bestD {
best, bestD = j, dd
}
}
lists[best] = append(lists[best], i)
}
all := make([]int, nVec)
for i := range all {
all[i] = i
}
queries := make([]vec, nQuery)
for i := range queries {
c := centers[r.Intn(nList)]
q := make(vec, dim)
for d := 0; d < dim; d++ {
q[d] = c[d] + r.NormFloat64()*1.4
}
queries[i] = q
}
bruteComp := 0
for range queries {
bruteComp += nVec
}
fmt.Println("=== IVF 索引:5000 条 32 维向量、50 个聚类、top-10 ===")
fmt.Println("nprobe 平均扫描向量 距离计算次数 recall@10")
for _, nprobe := range []int{1, 5, 10, 20, 50} {
hits, comp, scanned := 0, 0, 0
for _, q := range queries {
type cp struct {
id int
d float64
}
cs := make([]cp, nList)
for j, c := range centers {
cs[j] = cp{j, dist(q, c)}
}
sort.Slice(cs, func(i, j int) bool { return cs[i].d < cs[j].d })
cand := []int{}
for k := 0; k < nprobe; k++ {
cand = append(cand, lists[cs[k].id]...)
scanned += len(lists[cs[k].id])
}
got := topKIDs(q, data, cand, topK)
truth := topKIDs(q, data, all, topK)
set := map[int]bool{}
for _, id := range got {
set[id] = true
}
for _, id := range truth {
if set[id] {
hits++
}
}
comp += len(cand)
}
fmt.Printf("%-8d %-14d %-13d %.3f\n", nprobe, scanned/nQuery, comp/nQuery,
float64(hits)/float64(nQuery*topK))
}
fmt.Printf("暴力基线 %-14d %-13d 1.000\n", nVec, bruteComp/nQuery)
fmt.Println("\n结论:nprobe 从 1 加到 10,recall 上升、扫描量线性涨;RAG 检索是在 recall 与延迟之间选点。")
}
本机实测(macOS,Apple M1,Go 1.27.1):
=== IVF 索引:5000 条 32 维向量、50 个聚类、top-10 ===
nprobe 平均扫描向量 距离计算次数 recall@10
1 100 100 0.598
5 502 502 0.836
10 1007 1007 0.923
20 2008 2008 0.981
50 5000 5000 1.000
暴力基线 5000 5000 1.000
结论:nprobe 从 1 加到 10,recall 上升、扫描量线性涨;RAG 检索是在 recall 与延迟之间选点。
读数:
| nprobe | 扫描量 | recall@10 | 结论 |
|---|---|---|---|
| 1 | 100 | 59.8% | 快,但接近一半的相关文档没召回 |
| 5 | 502 | 83.6% | 常用工作点 |
| 10 | 1007 | 92.3% | 召回明显改善,扫描 10 倍 |
| 20 | 2008 | 98.1% | 接近暴力,延迟已不低 |
| 50 | 5000 | 100% | 等价暴力,失去 ANN 意义 |
🔑 这张表说明「向量检索没有免费午餐」:nprobe 从 1 到 20,召回从 60% 到 98%,扫描量从 100 到 2008。生产上要按「召回对最终答案的影响」选点,而不是盲目追求 100%——很多 RAG 场景 90% 召回 + 重排,比 99% 召回 + 高延迟更划算。
切分:比索引更影响效果
| 策略 | 做法 | 优点 | 坑 |
|---|---|---|---|
| 固定长度 | 每 512 token 切 | 简单、块大小均匀 | 切断句子/表格,语义不完整 |
| 按结构 | 按标题/段落/代码块 | 语义完整、易引用 | 块长不均,超长要二次切 |
| 父子块 | 子块检索、父块送模型 | 小块召回准、大块上下文全 | 需要两级存储 |
| 滑动窗口 | 块间重叠 10%–20% | 减少边界丢失 | 索引膨胀、重复结果 |
| 语义切分 | 按 embedding 相似度断点 | 语义连贯 | 计算贵、边界不稳定 |
⚠️ 块太大,检索精度下降、上下文浪费;块太小,语义不完整、模型无法回答。常见起点:中文 300–500 字、英文 200–400 token,重叠 10%–15%;最终要用评测集调,不是拍脑袋。
召回之后:重排与混合检索
| 手段 | 作用 | 成本 |
|---|---|---|
| 混合检索 | BM25 关键词 + 向量语义,互补 | 两套索引、融合排序 |
| RRF | 按排名融合多路结果 | 简单、无需调权重 |
| Cross-encoder 重排 | 对 top-k 逐对打分 | 延迟高,通常只重排 20–100 条 |
| LLM 重排/过滤 | 用模型判断相关性 | 成本最高,适合高价值查询 |
| 元数据过滤 | 按时间/权限/租户过滤 | 必须在召回前做,否则越权 |
🔑 权限过滤要放在召回前,不能等召回后再删:先召回再过滤会让「无权文档」进入模型上下文,既泄露又浪费 token。多租户 RAG 的索引要带 tenant 分区/过滤字段(见 60.6 数据安全与权限)。
上下文组装:RAG 的最后一步
| 要素 | 做法 | 失败模式 |
|---|---|---|
| 预算 | 按 token 上限分配「系统提示/问题/证据/输出」 | 证据塞满,模型没空间回答 |
| 排序 | 最相关放前面/中间(注意 lost-in-the-middle) | 关键证据在中间被忽略 |
| 引用 | 每段带 [doc_id] / URL / 行号 | 模型编造来源,无法审计 |
| 冲突 | 多来源冲突时标注时间/版本 | 模型随机选一个 |
| 指令 | 明确「只依据证据回答,不足则说不知道」 | 模型用参数知识补编 |
⚠️ 上下文不是越多越好:长上下文会稀释注意力(context rot),且成本线性涨。先用重排把 top-k 压到 5–10 条,再按预算截断;宁可少而准,不要多而杂。
生产落地:指名 → 可抄 → 可验 → 可退
指名:向量库用 Milvus(大规模、IVF/HNSW)、Qdrant(Rust、过滤强)、pgvector(与 Postgres 同库)、Elasticsearch/OpenSearch(混合检索);嵌入模型按语言/领域选,重排用 cross-encoder。
可抄(Qdrant 1.x,未验证:本机未安装 Qdrant):
curl -X PUT 'http://localhost:6333/collections/docs' \
-H 'Content-Type: application/json' \
-d '{"vectors":{"size":2,"distance":"Cosine"}}'
curl -X PUT 'http://localhost:6333/collections/docs/points' \
-H 'Content-Type: application/json' \
-d '{"points":[{"id":1,"vector":[0.1,0.2],"payload":{"tenant":"t1","text":"..."}}]}'
curl -X POST 'http://localhost:6333/collections/docs/points/search' \
-H 'Content-Type: application/json' \
-d '{"vector":[0.1,0.2],"limit":10,"filter":{"must":[{"key":"tenant","match":{"value":"t1"}}]}}'
可验:本节程序输出 nprobe/recall 表;生产上用「人工标注的查询-文档对」测 recall@k、MRR、nDCG,再用端到端评测(答案正确率/引用准确率)。可退:索引与 embedding 版本绑定,换模型时双索引双读;检索参数(nprobe/top-k)可热调;召回异常时降级到 BM25。
反模式
| 反模式 | 后果 |
|---|---|
| 只换更大的 embedding 模型,不改切分 | 块不完整,召回仍差 |
| 用暴力索引扛亿级数据 | 延迟不可接受 |
| nprobe 设 1 追求低延迟 | 召回 60%,答案大量缺失 |
| 召回后才做权限过滤 | 无权文档进上下文,泄露 |
| 把 top-50 全塞进 prompt | 超预算、注意力稀释、成本高 |
| 不加重排 | top-k 里有相关文档但排不到前面 |
| 没有引用/来源 | 无法审计,模型编造无法发现 |
| 用训练集问题评估 RAG | 过拟合,线上效果差 |
自检
Q1 RAG 的六段流水线里,哪两段最容易被低估?为什么?
▸ 答案:切分与上下文组装。切分决定每个块是否语义自包含,切错会让检索再准也答不全;组装决定模型看到的证据是否完整、是否知道来源、是否超预算。换 embedding 模型救不了切分错误,换向量库也救不了组装错误。
Q2 向量检索的 recall@10 是相对什么算的?为什么不能只看「用了 HNSW」?
▸ 答案:recall@10 是「ANN 返回的 top-10 与暴力 top-10 的交集 / 10」。HNSW/IVF 都是近似索引,参数(ef/nprobe)决定召回;不测真实查询集,无法知道召回率。本节 nprobe=1 只有 59.8%,nprobe=20 才 98.1%。
Q3 nprobe 从 1 加到 20,召回和扫描量各怎么变?怎么选?
▸ 答案:召回 59.8% → 98.1%,扫描量 100 → 2008(约 20 倍)。选点要看「召回提升对最终答案的边际收益」与延迟预算:如果重排/生成能容忍少量缺失,nprobe=5–10 可能够;高价值、低延迟不敏感场景再加大。没有通用最优值。
Q4 为什么权限过滤必须放在召回前?
▸ 答案:召回后再过滤,无权文档已经进入候选/上下文,可能被模型看到或泄露;同时浪费 token 与算力。正确做法是在索引里带 tenant/ACL 字段,检索时用 filter 做前置过滤(Qdrant payload filter、Milvus expr、pgvector WHERE)。
Q5 块大小怎么定?太大/太小各有什么问题?
▸ 答案:块太大,检索精度下降、噪声多、上下文浪费;块太小,语义不完整、答案缺上下文。起点中文 300–500 字/英文 200–400 token、重叠 10%–15%,再用评测集调;结构化文档优先按标题/段落切,代码按函数/代码块切。
Q6 混合检索与重排分别解决什么问题?
▸ 答案:混合检索(BM25 + 向量)解决「关键词精确匹配」与「语义相似」各自漏召回的问题;重排(cross-encoder/LLM)解决「召回候选相关但排序不准」的问题。前者扩大召回,后者提升 top-k 精度;两者不能互相替代。
Q7 RAG 怎么评估?只看答案像不像行不行?
▸ 答案:不行。要分层评估:检索层 recall@k/MRR/nDCG;生成层答案正确率、引用准确率、忠实度(是否只依据证据)、拒答率;端到端人工/LLM judge + A/B。只看「像不像」会奖励编造;必须有「证据支持」与「不知道」的判据。