用户问一个问题,等了 8 秒才出第一个字——很多人第一反应是「换更快的模型」,但剖析后发现:向量检索花了 1.5s,重排花了 2s,上下文拼装 0.5s,生成首 token 只花了 1s。RAG 的延迟是四段串联的,只盯生成等于漏掉了大部分问题。RAG 推理链路优化的核心,是把「检索 → 重排 → 构造 → 生成」每一段都压下来,并让能并行的部分真正并行起来。
一、延迟分解:先看清钱花在哪
RAG 链路的四段及其典型耗时:
RAG 端到端延迟分解:
① 检索(Retrieval)
□ query 向量化 ~20-100ms
□ 向量检索(Top-100) ~10-200ms(取决于索引与规模)
□ 关键词检索(可选) ~10-50ms(混合检索)
② 重排(Rerank)
□ 交叉编码器打分 ~200ms-2s(取决于候选数)
③ 上下文构造(Context Assembly)
□ 去重、截断、模板拼装 ~5-50ms
④ 生成(Generation)
□ Prefill(长上下文) ~200ms-2s
□ Decode(逐 token) 每 token 10-50ms
→ 用户体感 = TTFT ≈ ①+②+③+prefill
| 阶段 | 典型占比 | 优化重点 |
|---|---|---|
| 检索 | 20-40% | 索引、缓存、混合检索 |
| 重排 | 20-40% | 候选裁剪、批处理、蒸馏 |
| 上下文构造 | 5% | 去重、精简 |
| 生成(TTFT) | 20-40% | 前缀缓存、chunked prefill |
关键认知:
用户感知的是 TTFT(首 token 时间),而 TTFT = 检索 + 重排 + 构造 + prefill
→ 优化 TTFT 必须四段一起看,只优化 decode 对 TTFT 没用
→ 重排常是「隐形大头」:候选 100 个,交叉编码器逐个打分,几百 ms 起
工程要点:优化的第一步是**「测量每段耗时」**——用埋点把四段拆开计时(对应 LLM 服务可观测性 )。不看分解就优化,很容易在只占 20% 的生成上下大力气,而真正的瓶颈(常是重排)纹丝不动。
二、检索阶段优化
2.1 索引与检索方式
向量索引选型(影响检索延迟):
□ HNSW:高召回、低延迟,内存占用大 → 在线首选
□ IVF:内存小,需 nprobe 调优 → 大规模
□ PQ/SQ:量化压缩,省内存、略降精度 → 超大规模
检索方式:
□ 纯向量:语义相似
□ 纯关键词(BM25):精确匹配
□ 混合检索(Hybrid):向量 + BM25 融合 → 召回更好
→ 融合用 RRF(倒数排名融合)或加权
# 混合检索 + RRF 融合(概念示意)
def hybrid_search(query, top_k=100):
vec_hits = vector_index.search(embed(query), k=top_k)
kw_hits = bm25.search(query, k=top_k)
# RRF:1/(k + rank),合并两个排序
scores = {}
for rank, doc in enumerate(vec_hits):
scores[doc.id] = scores.get(doc.id, 0) + 1/(60 + rank)
for rank, doc in enumerate(kw_hits):
scores[doc.id] = scores.get(doc.id, 0) + 1/(60 + rank)
return sorted(scores, key=scores.get, reverse=True)[:top_k]
2.2 检索缓存
检索缓存分层:
□ query 向量缓存:相同 query 直接复用(精确哈希)
□ 检索结果缓存:相同 query → 相同 Top-K(TTL 短)
□ 语义缓存:相似 query 命中(embedding 相似度 > 阈值)
→ 见前缀缓存与语义缓存篇的完整设计
命中率来源:
□ 重复查询(FAQ、热门问题)
□ 语义相近的改写(语义缓存)
□ 多轮对话中的指代(同一会话)
工程要点:检索优化的两大杠杆是**「索引结构」与「缓存」**。在线优先 HNSW(低延迟高召回);混合检索(向量 + BM25)能显著提升召回质量,代价是多一次检索。缓存要分层——精确缓存兜重复查询,语义缓存兜相似改写,命中即省掉整段检索。
三、重排与上下文构造
3.1 重排优化:控制候选规模
重排常是链路里的「隐形大头」。核心是控制送进交叉编码器的候选数:
重排优化手段:
□ 候选裁剪:粗排召回 100 → 精排只处理 20-50
(用更便宜的轻量 reranker 先过一遍)
□ 批处理:候选成批送入,长度分桶
□ 蒸馏:大 reranker 蒸馏小模型,延迟减半
□ 量化:FP16/INT8
□ 异步/并行:与后续无关步骤重叠
候选数 vs 质量权衡:
候选少 → 快,但可能漏掉相关文档(召回损失)
候选多 → 准,但延迟线性增长
→ 通常 50 是甜点(召回与延迟平衡)
3.2 上下文构造
上下文构造的优化点:
□ 去重:不同 chunk 可能内容重叠 → 去掉冗余
□ 截断:按相关性排序后截断到预算 token 数
□ 压缩:长 chunk 抽取关键句(上下文压缩)
□ 排序:最相关的放开头或结尾(「lost in the middle」效应)
□ 模板:稳定前缀(system + 指令)→ 利于前缀缓存复用
「Lost in the Middle」效应:
LLM 对上下文「开头和结尾」的信息利用最好,
「中间」的信息容易被忽略。
→ 把最相关的文档放首尾,次要的放中间
→ 或按相关性降序排列,让最相关的排最前
工程要点:重排优化的关键是**「控制规模」**——候选数从 100 降到 50 甚至 20,延迟近乎线性下降,而召回损失有限。上下文构造要做「去重 + 截断 + 排序」:去重省 token,截断控预算,排序利用「首尾优先」效应。稳定的 system 前缀对后续生成的前缀缓存复用至关重要。
四、生成阶段优化
4.1 前缀缓存:RAG 的天然红利
RAG 每次请求都带一大段「检索到的文档」,但这些文档常重复出现(相同文档被多次检索到):
前缀缓存的两种收益:
① 相同 system prompt + 指令 → 稳定前缀,跨请求复用
② 相同检索文档被不同 query 引用 → 文档段落的 KV 复用
→ RAG 是前缀缓存的理想场景
→ 命中后 prefill 算力大幅下降,TTFT 显著降低
与 KV cache 的关系:
□ 前缀缓存复用「已算好的 KV」→ 省 prefill
□ PagedAttention 让复用粒度到「块」级别
□ RadixAttention(前缀树)自动发现共享前缀
→ 详见前缀缓存与语义缓存篇
4.2 Prefill 与 Chunked Prefill
RAG 的输入往往很长(多个文档拼起来),prefill 是 TTFT 的主要成分:
长 prefill 的问题:
□ 一次性 prefill 长上下文 → TTFT 高(用户干等)
□ 且会阻塞同批的 decode 请求(长 prefill 抢占)
解法:
□ Chunked prefill:把长 prefill 切成小块,与 decode 交错
→ 改善 TTFT 与整体吞吐(见 PD 分离篇)
□ 前缀缓存命中 → 直接跳过已缓存的 prefill
□ 上下文压缩 → 减少送入的 token 数
上下文长度 vs TTFT 的权衡:
文档越多 → 召回越好,但 prefill 越慢、KV cache 越占显存
→ 用「相关性截断」在预算内保留最相关文档
→ 目标:在质量可接受下,把上下文压到最短
工程要点:生成阶段的优化重心是 prefill(决定 TTFT),而非 decode。RAG 场景下,前缀缓存是性价比最高的手段——检索到的文档大量重复,复用它们的 KV 能直接砍掉 prefill 算力。用 chunked prefill 避免长 prefill 阻塞其他请求,用相关性截断控制上下文长度。
五、跨段流水线与并行
四段串联是「最坏情况」——很多步骤其实可以并行或重叠:
可并行/重叠的机会:
□ 向量检索与关键词检索 → 并行(两个独立查询)
□ 检索与「query 预处理」→ 部分重叠
□ 重排与「上下文模板准备」→ 重叠
□ 生成 prefill 与「后续文档加载」→ 重叠
流水线示意:
t0 ─┬─ 向量检索 ─┐
└─ BM25 检索 ─┴─ RRF 融合 ─ 重排 ─ 构造 ─ prefill ─ decode
(并行) ↑
与「模板准备」重叠
# 并行检索 + 并行打分(异步)
import asyncio
async def rag_pipeline(query):
# 并行:向量检索与关键词检索同时发起
vec_task = asyncio.create_task(vector_search(query))
kw_task = asyncio.create_task(bm25_search(query))
vec_hits, kw_hits = await asyncio.gather(vec_task, kw_task)
candidates = rrf_fuse(vec_hits, kw_hits)[:50]
# 重排(批处理,一次打分)
scores = await rerank_batch(query, candidates)
top = sorted(zip(candidates, scores), key=lambda x: -x[1])[:10]
context = build_context(top) # 去重 + 截断 + 排序
return await generate(query, context)
并行化的边界:
□ 有数据依赖的步骤不能并行(检索完才能重排)
□ 能并行的是「独立子步骤」(多路检索、独立打分)
□ 端到端优化 = 缩短关键路径 + 让非关键路径重叠
工程要点:RAG 链路优化的高级阶段是**「缩短关键路径」**——识别哪些步骤有数据依赖(必须串行)、哪些独立(可并行)。多路检索并行、批处理打分、与后续步骤重叠,都能缩短端到端延迟。但别过度并行化:复杂度过高会带来难以调试的并发问题,收益却有限。
六、缓存分层与端到端评测
6.1 缓存分层设计
RAG 的多层缓存(从快到慢):
Layer 1 结果缓存:完整问答对(相同 query 直接返回)
→ 最快,命中即 0 延迟;TTL 短,需失效策略
Layer 2 语义缓存:相似 query 复用答案
→ 命中率高,但需相似度阈值与一致性校验
Layer 3 检索缓存:query → Top-K 文档
→ 跳过检索,仍需重排与生成
Layer 4 前缀缓存:文档 KV 复用
→ 跳过 prefill,仍需 decode
→ 越靠前命中,省的越多
→ 分层设计:先查快层,未命中再往深走
缓存失效:
□ 文档更新 → 检索缓存与结果缓存都要失效
□ 用版本号/时间戳标记文档库状态
□ 语义缓存要防「近似但不等价」的 query 误命中
6.2 端到端评测
优化必须用指标验证,且要区分「延迟」与「质量」:
RAG 端到端指标:
延迟:
□ TTFT(首 token)← 优化重点
□ 各段耗时分解(检索/重排/构造/prefill)
□ P95/P99(长尾)
质量:
□ 检索:Recall@K、MRR
□ 生成:忠实度(faithfulness)、答案正确率
□ 端到端:人工/自动评测集通过率
成本:
□ 每请求 token 消耗(含上下文)
□ 缓存命中率带来的节省
优化验证纪律:
□ 每项优化前后跑同一评测集(延迟 + 质量)
□ 关注「质量是否下降」,而非只看「延迟是否下降」
□ 端到端 SLO:如「P95 TTFT < 2s 且 忠实度 > 0.9」
工程要点:RAG 优化的收尾是**「分层缓存 + 端到端评测」**。缓存分层(结果/语义/检索/前缀)让高频路径几乎零延迟;评测必须同时看延迟与质量——上下文压缩、候选裁剪、缓存命中都会影响答案质量,必须用固定评测集验证「质量损失可接受」。方法与 推理基准与压测 一致。
七、速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 先做什么 | 拆开测量四段耗时(检索/重排/构造/生成) |
| 用户感知 | TTFT = 检索 + 重排 + 构造 + prefill |
| 隐形大头 | 重排(候选多时几百 ms 起) |
| 检索优化 | HNSW 索引 + 混合检索 + 缓存 |
| 重排优化 | 控候选规模(50 甜点)+ 批处理 + 蒸馏 |
| 上下文构造 | 去重 + 截断 + 首尾优先排序 |
| 生成优化 | 前缀缓存(RAG 天然红利)+ chunked prefill |
| 并行化 | 多路检索并行、批打分、重叠非关键路径 |
| 缓存分层 | 结果 → 语义 → 检索 → 前缀,越前越省 |
| 评测纪律 | 延迟 + 质量双指标,固定评测集 |
一句话记忆:RAG 链路优化 = 拆开测量四段 + 检索用 HNSW/混合/缓存 + 重排控候选规模 + 构造去重截断排序 + 生成靠前缀缓存省 prefill + 多路并行缩短关键路径 + 分层缓存 + 延迟质量双评测——「先测量,再优化,别只盯生成」。
延伸阅读
- 前缀缓存与语义缓存:KV 复用与重复计算消除
- 长上下文推理:RoPE 扩展、位置插值与 Ring Attention
- Prefill/Decode 分离与连续批处理
- vLLM 深度解析:连续批处理与内存高效推理
- 推理基准与压测
- RAG 架构设计 — 应用层架构
- 混合检索 RAG — 向量 + 关键词融合
- 语义缓存与 Prompt 缓存 — 缓存机制详解
- MCP 向量检索与 RAG 工具 — 工具侧集成
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。