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。只看「像不像」会奖励编造;必须有「证据支持」与「不知道」的判据。

关联