RAG 响应慢?我用这四步把查询延迟从 8 秒降到了 0.8 秒

2026-05-03 23:08 RAG 响应慢?我用这四步把查询延迟从 8 秒降到了 0.8 秒已关闭评论

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:查询压缩 + 查询重写,提升召回命中率

检索结果少了,但如果第一步就漏召回了怎么办?我观察到有些问题用原文检索效果很差——比如用户问"怎么重置密码",文档里写的却是"凭据变更操作"。

用了两个技巧:

  1. HyDE(假设文档嵌入):先让一个小模型根据问题生成一段假设答案文本,用这段文本做向量检索。语义上更贴近文档的表达方式。
  2. 查询重写:对原始问题做关键词扩展和同义替换。
  3. 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 秒多,用户还是觉得"慢"。关键在于感知速度不等于实际速度

做了两件事:

  1. 流式输出:不等大模型生成完再一次性返回,边生成边推送
  2. 预填充(Prefill):检索阶段先给模型一个固定的思考开头,让模型在检索完成的同时就开始推理
  3. 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),还需要考虑:

  1. 向量数据库分片:目前用的单节点,QPS 上百后向量检索会先扛不住,需要做分片 + 合并
  2. 语义缓存:不仅缓存完全相同的 query,对语义相似的 query 也命中缓存。可以用 embedding 距离做模糊匹配
  3. 分级模型策略:简单问题用小模型(快、便宜),复杂问题走完整 RAG 链路。问题的"难度"可以拿 query 长度和意图分类来判断
  4. 增量索引:文档更新频繁时,全量重建索引成本太高。流式增量更新 + 定期合并是更实际的方案

目前我先把这些记在 backlog 里,等用户量再翻一倍再动手——过早优化是万恶之源。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4069.html ,谢谢支持!

资源分享

分类:Android 标签:
Python框架Flash_Restful安装使用 Python框架Flash_Restful安装
05-引用技巧学习 05-引用技巧学习
产品官网-Hero-品牌落地页 产品官网-Hero-品牌落地页
成员变量的隐藏和方法的重写 成员变量的隐藏和方法的重写

评论已关闭!