RAG 响应慢?我用这四步把查询延迟从 8 秒降到了 0.8 秒
背景:我维护的企业知识库 RAG 系统,上线后用户普遍抱怨"问一个问题要等很久",最慢的查询超过 8 秒。
问题在哪
上线第一个月,用户投诉率接近 30%。监控显示 P99 延迟 8.2 秒,平均 3.5 秒。更致命的是,并发一上来,数据库 CPU 直接飙到 90%,请求排队越来越长。
拆开链路一看,三个环节都在拖后腿:
- 文档检索慢——向量搜索和关键词搜索分别查一次,再合并排序
- 大模型推理慢——用的 32K 上下文模型,把所有检索结果都塞进去
- 最隐蔽的——检索结果太多导致 Token 消耗爆炸,推理时间线性增长
解决思路
我调研了三种方案:
| 方案 | 思路 | 预估效果 |
|---|---|---|
| 方案 A:换更快的模型 | 从 GPT-4 换成更小的模型 | 降一半延迟,但回答质量明显下降 |
| 方案 B:加缓存 | 对高频问题做结果缓存 | 命中率约 20%,天花板明显 |
| 方案 C:从检索+推理路径优化 | 减少检索量 + 优化推理输入 | 可叠加,理论上降 90% |
我选了方案 C。缓存和换模型都在治标,检索和推理的配合方式才是根因。实际做下来分了四步,每一步都有明确的指标提升。
操作步骤
步骤 1:双路检索 + 重排序,替代暴力搜索
原来的做法是把向量相似度搜索和 BM25 关键词搜索的结果合并,取 top 20 给大模型。问题在于:向量检索和关键词检索各返回 20 条,合并后实际给到重排序器的是 40 条,大量重复或低相关。
改成各取 10 条,合并后送重排序器,最终只保留 top 5。
from sentence_transformers import CrossEncoder
# 初始化重排序模型
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")
def retrieve_documents(query: str, top_k: int = 5):
# 向量检索取 top 10
vector_results = vector_store.similarity_search(query, k=10)
# BM25 关键词检索取 top 10
keyword_results = bm25_retriever.search(query, k=10)
# 合并去重(用 doc_id 去重)
seen = set()
candidates = []
for doc in vector_results + keyword_results:
if doc.id not in seen:
seen.add(doc.id)
candidates.append(doc)
# 重排序
pairs = [[query, doc.text] for doc in candidates]
scores = reranker.predict(pairs)
# 按重排序得分取 top_k
ranked = sorted(
zip(candidates, scores),
key=lambda x: x[1],
reverse=True
)[:top_k]
return [doc for doc, _ in ranked]
注意:重排序模型虽然增加了一次推理开销,但输入从 40 条减到 20 条,实际重排序耗时只有 30-50ms,后续大模型省掉的 Token 是几千级别,非常划算。
这一步之后,检索质量提升,传给大模型的文档从 20 条降到 5 条,P99 延迟从 8.2s 降到了 5.1s。
步骤 2:查询压缩 + 查询重写,提升召回命中率
检索结果少了,但如果第一步就漏召回了怎么办?我观察到有些问题用原文检索效果很差——比如用户问"怎么重置密码",文档里写的却是"凭据变更操作"。
用了两个技巧:
- HyDE(假设文档嵌入):先让一个小模型根据问题生成一段假设答案文本,用这段文本做向量检索。语义上更贴近文档的表达方式。
- 查询重写:对原始问题做关键词扩展和同义替换。
from langchain.llms import OpenAI
def rewrite_query(original_query: str) -> str:
"""用 LLM 重写查询,让它更贴近文档的表达方式"""
prompt = f"""你是一个搜索专家。请将用户的提问改写成更适合文档检索的形式。
要求:
- 保留核心意图
- 使用更正式、文档化的表达
- 可以补充同义词和近义词
用户提问:{original_query}
改写后的查询:"""
rewritten = llm.invoke(prompt)
return rewritten
def hyde_generate(question: str) -> str:
"""HyDE:先生成假设文档,再用它去检索"""
prompt = f"""Based on the following question, write a passage that answers it.
Question: {question}
Passage:"""
hypothetical_doc = small_llm.invoke(prompt)
return hypothetical_doc
线上我选了只做查询重写,不做 HyDE。HyDE 额外多一次模型调用,收益(召回率 +2%)和成本不成正比。查询重写用 GPT-4o-mini,单次不到 100 Token,成本可以忽略,召回率提升了约 12%。
步骤 3:优化 Prompt,告诉 LLM"不知道就直说"
这个坑最丢脸——查了很多资料才发现,大部分延迟来自大模型在强行推理无关内容。
原先的 Prompt 只说"根据上下文回答",LLM 会把 5 条文档每条都读一遍,即使其中 4 条和问题无关,它也会尝试从中"编造"答案。不仅慢,还容易幻觉。
SYSTEM_PROMPT = """你是一个知识库助手。请严格遵循以下规则:
1. **只使用搜索结果中与问题直接相关的内容回答**
2. **如果搜索结果不足以回答问题,直接说"根据现有资料无法回答这个问题"**
3. 不要编造信息,不要使用自己的知识补充
4. 如果问题需要分步骤说明,用有序列表
5. 回答要简洁,控制在 3 段以内
搜索结果:
{context}
用户问题:
{question}
"""
def build_prompt(question: str, documents: list) -> str:
# 只保留重排序得分最高的 3 条
top_docs = documents[:3]
context = "
".join([
f"[来源 {i+1}] {doc.text[:2000]}"
for i, doc in enumerate(top_docs)
])
return SYSTEM_PROMPT.format(context=context, question=question)
注意:给每条结果加上
[来源 1]的标记,方便大模型引用。每段文本限制不超过 2000 字,防止单条结果太长。
这一步之后,每次推理的输入 Token 减少约 60%,P99 从 5.1s 降到了 2.3s。
步骤 4:流式输出 + 预填充,提升用户感知速度
延迟降到了 2 秒多,用户还是觉得"慢"。关键在于感知速度不等于实际速度。
做了两件事:
- 流式输出:不等大模型生成完再一次性返回,边生成边推送
- 预填充(Prefill):检索阶段先给模型一个固定的思考开头,让模型在检索完成的同时就开始推理
from fastapi import FastAPI, StreamingResponse
from openai import OpenAI
app = FastAPI()
client = OpenAI()
async def stream_answer(query: str):
documents = retrieve_documents(query, top_k=3)
messages = [
{"role": "system", "content": build_system_prompt(documents)},
{"role": "user", "content": query},
# Prefill:引导模型直接开始回答,减少思考时间
{"role": "assistant", "content": "根据搜索结果,我来回答这个问题。"}
]
stream = client.chat.completions.create(
model="gpt-4o",
messages=messages,
stream=True,
max_tokens=1024,
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
@app.post("/chat")
async def chat(query: str):
return StreamingResponse(
stream_answer(query),
media_type="text/plain"
)
前端配合流式渲染,用户在 0.5 秒内就能看到第一个字开始输出,感知延迟从 2.3s 降到了"几乎即时"。
结果与总结
最终效果:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P99 延迟 | 8.2s | 0.8s |
| 平均延迟 | 3.5s | 0.4s |
| 数据库 CPU | 90% | 25% |
| 用户投诉率 | 30% | < 2% |
四个步骤的收益排序:Prompt 优化 > 流式输出 > 检索剪枝 > 查询重写。
几个印象深刻的坑:
- 重排序不是万能的:第一次我把重排序得分最高的 10 条都送进去,结果前几条高度相似(都是同一份文档的不同 chunk),浪费了上下文窗口。后面加了去重 + MMR(最大边际相关性)才解决。
- Prefill 要小心:给模型的预填充文本不能太长,否则模型会直接忽略后面的检索结果,完全按预填的套路走。控制在 15 个字以内是安全线。
- 指标会骗人:优化前期我看 P50 延迟降得很快,以为问题解决了。上线后用户还是说慢。后来才意识到 P99 才是关键——那 1% 的慢请求决定了用户体感。
延伸思考
这篇文章只聊了"单次查询"的优化。如果并发量再上一个量级(比如 QPS > 100),还需要考虑:
- 向量数据库分片:目前用的单节点,QPS 上百后向量检索会先扛不住,需要做分片 + 合并
- 语义缓存:不仅缓存完全相同的 query,对语义相似的 query 也命中缓存。可以用 embedding 距离做模糊匹配
- 分级模型策略:简单问题用小模型(快、便宜),复杂问题走完整 RAG 链路。问题的"难度"可以拿 query 长度和意图分类来判断
- 增量索引:文档更新频繁时,全量重建索引成本太高。流式增量更新 + 定期合并是更实际的方案
目前我先把这些记在 backlog 里,等用户量再翻一倍再动手——过早优化是万恶之源。

评论已关闭!