"RAG 太慢"这句话没有信息量。一次请求里有六段耗时,有的毫秒级、有的几秒级,优化错地方等于白改:把向量检索从 40ms 压到 20ms,用户完全感知不到;而把首 token 从 2.8s 降到 1.2s,体感是质的差别。
所以性能问题的第一件事不是优化,是分段计时。
1. 六段耗时与典型量级
| 段落 | 做什么 | 典型量级(自测,非基准) | 占比感 |
|---|---|---|---|
| 查询改写 | 补全指代、扩写、抽取过滤条件 | 10–300ms(含小模型调用则更高) | 小 |
| embedding | 查询向量化 | 5–50ms(API 更高) | 小 |
| 向量检索 | ANN + filter | 5–100ms | 小到中 |
| rerank | 交叉编码器重排 | 30–400ms(随候选数线性) | 中 |
| LLM 首 token | 排队 + 预填充 | 200ms–3s | 大 |
| LLM 生成 | 逐 token 解码 | 由输出长度决定 | 最大 |
前四段加起来常常不到 500ms,而用户抱怨的"慢"几乎总在最后两段。优化资源应该按这个比例分配。
import time, functools
def timed(name, sink):
def deco(fn):
@functools.wraps(fn)
def wrap(*a, **kw):
t0 = time.perf_counter()
try:
return fn(*a, **kw)
finally:
sink[name] = round((time.perf_counter() - t0) * 1000, 1)
return wrap
return deco
SPANS = {}
@timed("rewrite", SPANS)
def rewrite(q): ...
@timed("embed", SPANS)
def embed(q): ...
@timed("search", SPANS)
def search(v): ...
def handle(q):
q2 = rewrite(q)
v = embed(q2)
hits = search(v)
SPANS["total"] = None
return hits
典型输出(示意):
{'rewrite': 12.4, 'embed': 18.9, 'search': 41.2, 'rerank': 96.7,
'llm_ttft': 810.0, 'llm_gen': 4260.0}
看到这张表就知道:rerank 的 96ms 值得看一眼,search 的 41ms 不值得。
2. 各段优化手段
| 段落 | 手段 | 收益 | 代价 |
|---|---|---|---|
| 查询改写 | 规则优先,小模型兜底;并发做 | 10–200ms | 逻辑复杂 |
| embedding | 批处理、结果缓存、本地小模型 | 5–40ms | 缓存失效管理 |
| 向量检索 | 调 ef_search、加 filter 下推 |
10–80ms | 召回下降 |
| rerank | 候选 20–30 条,输入截断 | 50–300ms | 精度略降 |
| 首 token | 前缀缓存、缩短 prompt、并发排队 | 数百 ms–2s | 需模型侧支持 |
| 生成 | 流式输出、控制输出长度 | 感知延迟大幅下降 | 无 |
3. embedding:批处理与缓存
单条编码的固定开销(网络、序列化、模型启动)远大于计算本身。所以在线只编一条是浪费,离线必须批量。
def embed_batched(texts, model, batch=64):
out = []
for i in range(0, len(texts), batch):
chunk = texts[i:i + batch]
out.extend(model.encode(chunk, normalize_embeddings=True))
return out
_cache = {}
def embed_query(q, model):
key = (model.version, q.strip())
if key not in _cache:
_cache[key] = model.encode([q], normalize_embeddings=True)[0]
return _cache[key]
| 缓存对象 | 命中率 | 失效条件 | 建议 |
|---|---|---|---|
| 查询向量 | 高频问题高 | 模型版本变更 | 必做 |
| 整篇答案 | 中(FAQ 类高) | 语料/模型变更 | 高风险场景慎用 |
| rerank 结果 | 低 | 候选集变化即失效 | 一般不做 |
| 检索结果 | 中 | 索引更新 | 用短 TTL |
注意 _cache 的 key 必须带模型版本——否则换模型后旧向量继续被复用,静默召回错乱(见语义漂移那篇)。
4. 向量检索:参数是第一杠杆
| 参数 | 影响延迟的方式 | 低延迟取值 | 代价 |
|---|---|---|---|
ef_search |
候选池宽度 | 32–64 | 召回下降 |
num_candidates |
ES 的候选预算 | 2×k 起 |
过滤场景召回不足 |
| 索引类型 | 图 vs 倒排 | HNSW | 内存 |
| filter 下推 | 是否在索引内过滤 | 尽量下推 | 依赖引擎 |
| 副本数 | 并发能力 | 按 QPS 定 | 内存与成本 |
def search_params(k, filtered: bool):
return {
"hnsw_ef": 128 if filtered else 64, # 过滤场景给足
"exact": False,
"params": {"k": k},
}
一个常被忽略的点:返回字段越少越快。只取 id / title / text,不要 SELECT * 把整条文档连同向量一起搬出来——序列化开销常常比检索本身还大。
5. rerank:候选数决定一切
交叉编码器的耗时近似正比于 候选数 × 输入长度。两个旋钮都能剪。
def rerank_budgeted(q, cands, reranker, max_chars=1200, top_n=8):
inputs = [c["text"][:max_chars] for c in cands]
scores = reranker.score(q, inputs) # 批量调用,别逐条
order = sorted(range(len(scores)), key=lambda i: -scores[i])
return [cands[i] for i in order[:top_n]]
| 候选数 | 延迟(相对) | 精度 | 建议 |
|---|---|---|---|
| 10 | 1× | 一般(漏掉好答案) | 小语料可用 |
| 20–30 | 2–3× | 接近饱和 | 推荐区间 |
| 50 | 5× | 饱和 | 只在离线评测用 |
| 100+ | 10× | 无提升 | 不要 |
批量调用与逐条调用的差距常在 3–10 倍,因为每条都要付一次请求开销。这是最容易拿到的性能优化。
6. 首 token:前缀缓存与 prompt 结构
预填充(prefill)的成本正比于输入 token 数,而输入里最大的一块通常是固定的系统提示 + 检索上下文。
| 手段 | 机制 | 效果 |
|---|---|---|
| 前缀缓存 | 相同前缀的 KV 复用,跳过重复预填充 | 首 token 明显下降(按所用推理框架是否支持核对) |
| 缩短系统提示 | 直接减少 token | 线性下降 |
| 上下文裁剪 | 少放无关 chunk | 线性下降 + 质量提升 |
| 并发排队优化 | 减少等待 | 看排队分布 |
前缀缓存生效的前提是稳定前缀:系统提示、few-shot 示例必须逐字节一致,且放在最前面。把动态内容(时间戳、用户 ID)插在系统提示里,缓存就永远命不中。
SYSTEM = """你是资料检索助手。规则固定如下:
1) 只使用编号资料中的事实;2) 每个事实标注 [n];3) 资料不足时明确说明。"""
def build_prompt(q, ctx):
# 动态内容一律放在稳定前缀之后
blocks = [f"[{i}] {c['text']}" for i, c in enumerate(ctx, 1)]
return f"{SYSTEM}\n\n资料:\n" + "\n".join(blocks) + f"\n\n问题:{q}"
7. 流式输出:把感知延迟和真实延迟分开
| 指标 | 定义 | 优化目标 |
|---|---|---|
| 真实延迟 | 从请求到最后一字 | 总成本 |
| 感知延迟 | 从请求到第一字出现 | 首 token |
| 完成率 | 用户读到结尾的比例 | 内容质量 |
用户对"1.5 秒开始出字、8 秒写完"的容忍度远高于"8 秒后一次性出现"。所以即使总耗时不变,流式也能明显改善体感。
def stream_answer(prompt, llm):
first = None
t0 = time.perf_counter()
for delta in llm.stream(prompt):
if first is None:
first = time.perf_counter() - t0
yield {"event": "ttft", "ms": round(first * 1000, 1)}
yield {"event": "delta", "text": delta}
yield {"event": "done", "ms": round((time.perf_counter() - t0) * 1000, 1)}
配套的产品侧动作:首字之前给进度提示("正在检索 3 个文档"),否则流式的优势会被空白等待抵消。
8. 并发下的资源竞争
单请求快不等于系统快。并发上来后,瓶颈会从"计算"变成"排队"。
| 资源 | 竞争表现 | 监控 | 处理 |
|---|---|---|---|
| GPU 显存 | 批大小受限、OOM | 显存占用、批队列长度 | 限制并发、动态批 |
| 模型连接数 | 排队、超时 | p95 排队时长 | 连接池 + 信号量 |
| 向量库连接 | 连接耗尽、握手开销 | 活跃连接数 | 连接池复用 |
| 外部 API 配额 | 限流 | 429 计数 | 退避 + 本地缓存 |
| 线程/GIL(Python) | CPU 段串行 | CPU 利用率 | 异步 IO、进程池 |
import asyncio
SEM = asyncio.Semaphore(8) # 保护下游模型服务
async def call_llm(prompt):
async with SEM:
return await client.generate(prompt)
信号量的大小要按下游能承受的并发定,不是按"我们希望多快"定。设太大只会把排队从本地搬到下游,超时反而更多。
9. 监控埋点:埋哪几段
| 埋点 | 类型 | 用途 |
|---|---|---|
| 每段耗时 | histogram | 定位瓶颈 |
| 首 token 时间 | histogram | 感知延迟 |
| 检索命中数 | gauge | 关联质量与延迟 |
| rerank 候选数 | gauge | 解释延迟波动 |
| 拒答率 | counter | 与阈值联动 |
| 端到端 p50/p95/p99 | histogram | SLO |
必须用 p95/p99,不看均值。 均值 800ms 可能对应 p99 是 6s,而用户记住的是那 1% 的糟糕体验。
def observe(spans, sink_hist):
for name, ms in spans.items():
if isinstance(ms, (int, float)):
sink_hist.observe(name, ms / 1000)
10. 排错:五种"改了不觉得快"
| 现象 | 原因 | 处理 |
|---|---|---|
| 优化检索后体感无变化 | 检索只占 10% | 先看分段表,改首 token |
| 首 token 没降 | 系统提示里含动态内容,前缀缓存未命中 | 把动态内容后移 |
| 并发一上来全慢 | 下游连接数打满 | 信号量 + 连接池 |
| rerank 越调越慢 | 候选数被悄悄放大 | 固定候选数并监控 |
| p50 好看但投诉多 | 长尾没管 | 看 p99,限制最大输出长度 |
11. 边界
| 场景 | 说明 |
|---|---|
| 离线批处理 | 关注吞吐,不是延迟,可大 batch |
| 嵌入式/端侧 | 显存与内存是硬约束,模型要换小 |
| 强实时(语音) | 首 token 是唯一指标,宁可少检索 |
| 高并发低价值查询 | 加缓存层,别硬优化链路 |
| 多轮对话 | 历史增长会推高预填充,必须做窗口裁剪 |
12. 自测
- 一次 RAG 请求的六段耗时里,哪两段通常占绝对多数?
- 为什么"返回字段只取需要的"能明显提速?
- rerank 的候选数为什么建议在 20–30,而不是 50?
- 前缀缓存生效的前提是什么?把时间戳放进系统提示会怎样?
- 流式输出降低了真实延迟吗?它改善了什么?
- 为什么监控必须看 p99 而不是均值?