RAG 系列(二十一):性能优化——又快又省钱

RAG 的成本结构

一次 RAG 请求做了什么:

scss 复制代码
1. embed(question)          → 1 次 Embedding API 调用
2. vectorstore.search()     → 向量库检索(本地,快)
3. llm.generate(context)    → 1 次 LLM API 调用

每次请求至少 2 次 API 调用。在高并发场景下,这两项会快速累积:

  • 延迟:LLM 调用通常 1--10 秒,Embedding 调用 0.1--0.5 秒
  • 费用:按 token 计费,相同问题每次都重新计算等于白花钱

这四种优化分别针对这条链路的不同位置:

优化 作用位置 节省什么
LLM 响应缓存 LLM 调用 完全跳过 LLM,0ms 响应
Embedding 缓存 Embedding 调用 相同文本不重复 embed
Semantic Cache LLM 调用 相似问题复用答案
异步批量 Embedding Embedding 调用 N 次串行变 1 次并发

优化一:LLM 响应缓存

原理:相同的 (prompt, model, temperature) 组合对应唯一的 LLM 调用,第一次调用后把结果存起来,下次直接返回,完全跳过 LLM。

LangChain 把这个做成了全局开关:

python 复制代码
from langchain_core.globals import set_llm_cache
from langchain_community.cache import InMemoryCache

set_llm_cache(InMemoryCache())   # 一行开启,对所有 LLM 调用生效

也可以用 SQLite 持久化(重启服务后缓存不丢失):

python 复制代码
from langchain_community.cache import SQLiteCache
set_llm_cache(SQLiteCache(database_path=".llm_cache.db"))

实验结果

3 个问题,每个问 2 遍:

yaml 复制代码
Q: RAGAS 有哪四个核心指标?
  Cache miss:  1743ms   Cache hit:   0.7ms   Speedup: 2441×

Q: 向量数据库有哪些常见选型?
  Cache miss:  3675ms   Cache hit:   0.9ms   Speedup: 4126×

Q: 什么是 Rerank?
  Cache miss:  9753ms   Cache hit:   0.9ms   Speedup: 10993×

平均: miss=5057ms  hit=0.8ms  speedup=6068×

hit 时间是 0.8ms,这是字典查询的延迟,不是网络延迟。缓存命中时完全没有网络请求。

6000× 加速听起来很夸张,但这就是"内存字典 vs. 网络 API 调用"的实际差距。

适用场景:FAQ 类问答、报告生成(同一用户多次点击"重新生成")、内容相同但被多用户重复提问的场景。

局限:只对完全相同的 prompt 生效。用户换个说法就是 miss。


优化二:Embedding 缓存

原理 :同一段文本的 embedding 向量是确定的(相同模型 + 相同文本 = 相同向量)。CacheBackedEmbeddings 在底层 embedding 上包了一层 ByteStore,第一次 embed 后把结果序列化存起来,之后直接读缓存。

python 复制代码
from langchain_classic.embeddings import CacheBackedEmbeddings
from langchain_classic.storage import InMemoryByteStore, LocalFileStore

# 内存存储(进程内,重启丢失)
store = InMemoryByteStore()

# 文件存储(持久化,重启后仍有效)
# store = LocalFileStore("./embedding_cache/")

cached_embeddings = CacheBackedEmbeddings.from_bytes_store(
    underlying_embeddings=base_embeddings,
    document_embedding_cache=store,
    namespace=EMB_MODEL,   # 不同模型的缓存互相隔离,换模型不会读到旧向量
)

# 用法和普通 embeddings 完全一样
vectorstore = Chroma.from_documents(docs, embedding=cached_embeddings)

namespace=EMB_MODEL 是重要细节:如果换了 embedding 模型,旧缓存的向量维度和分布都可能不同,用 model name 作 namespace 可以保证新模型不会读到旧向量。

实验结果

8 段文本,三轮对比:

复制代码
首次索引(8 条,全新):
  285ms   API 调用 1 次   发送文本 8 条

重复索引(8 条,全部缓存):
  5.7ms   API 调用 0 次   发送文本 0 条

知识库更新(6 旧 + 2 新):
  63.5ms  API 调用 1 次   发送文本 2 条

重点看第三行:知识库更新时,6 篇旧文档直接从缓存读向量,只有 2 篇新文档需要调用 API------这和上一篇的 Indexing API 结合使用效果最好:内容哈希追踪确定哪些文档需要重新索引,Embedding 缓存确保相同内容不重复 embed。

适用场景:知识库有大量固定内容 + 少量动态更新的场景。文档越多、更新频率越低,缓存收益越大。


优化三:Semantic Cache

原理:LLM 响应缓存只匹配完全相同的 prompt。Semantic Cache 更进一步:把历史问题和答案存成向量,新问题来时先做最近邻搜索,如果找到语义足够相似的历史问题,直接返回它的答案,完全跳过检索和 LLM。

arduino 复制代码
"RAGAS 框架有哪几个评估指标?"  → cache miss → LLM 生成 → 存入缓存
"请介绍一下 RAGAS 的四个核心指标" → 向量搜索 → 找到上面那条
                                   → 相似度 ≥ 阈值 → 返回缓存答案

实现:

python 复制代码
class SemanticCache:
    def __init__(self, embeddings, threshold: float = 0.85):
        # 用向量库存储历史问题
        self._store   = Chroma(collection_name="semantic_cache", ...)
        self._answers = {}          # cache_id → answer
        self._threshold = threshold

    def get(self, question: str) -> Optional[str]:
        results = self._store.similarity_search_with_relevance_scores(question, k=1)
        if results:
            doc, score = results[0]
            if score >= self._threshold:
                return self._answers[doc.metadata["cache_id"]]
        return None

    def set(self, question: str, answer: str) -> None:
        cache_id = str(uuid.uuid4())
        self._store.add_texts([question], metadatas=[{"cache_id": cache_id}])
        self._answers[cache_id] = answer

实验结果:阈值校准是核心难题

arduino 复制代码
阈值设置:0.85

RAGAS 指标组:
  原始问题: "RAGAS 框架有哪几个评估指标?"      → miss (3782ms)
  相似改写: "请介绍一下 RAGAS 的四个核心指标"   → miss (3298ms) ← 预期 HIT
  不同主题: "向量数据库的选型建议是什么?"        → miss (2509ms) ← 正确 miss

Rerank 组:
  原始问题: "Rerank 在 RAG 中起什么作用?"       → miss (11602ms)
  相似改写: "RAG 系统中为什么要做重排序?"        → miss (3834ms) ← 预期 HIT
  不同主题: "什么是混合检索?"                    → miss (12578ms) ← 正确 miss

总命中率:0/6

相似改写没有命中缓存。这不是代码 bug,而是阈值 0.85 对这些中文改写对来说太高了

原因:bge-large-zh-v1.5 在这两对问题上的余弦相似度可能在 0.80--0.84 之间,刚好低于 0.85。语义相似不等于向量相似度高,取决于 embedding 模型的表示空间和训练数据。

正确的做法:在设置阈值前先校准。用你的实际问题样本测量相似度分布:

python 复制代码
# 校准步骤:测量已知相似问对和不相关问对的相似度
from numpy import dot
from numpy.linalg import norm

def cosine(a, b):
    return dot(a, b) / (norm(a) * norm(b))

# 相似问对(应该命中缓存)
similar_pairs = [
    ("RAGAS 有哪些指标?", "RAGAS 的评估指标有哪几个?"),
    ("向量数据库怎么选?", "如何选择向量数据库?"),
]
# 不相关问对(不应该命中缓存)
dissimilar_pairs = [
    ("RAGAS 有哪些指标?", "向量数据库怎么选?"),
]

for q1, q2 in similar_pairs:
    v1 = embeddings.embed_query(q1)
    v2 = embeddings.embed_query(q2)
    print(f"Similar:    {cosine(v1, v2):.3f} --- {q1[:20]} / {q2[:20]}")

for q1, q2 in dissimilar_pairs:
    v1 = embeddings.embed_query(q1)
    v2 = embeddings.embed_query(q2)
    print(f"Dissimilar: {cosine(v1, v2):.3f} --- {q1[:20]} / {q2[:20]}")

# 阈值应设在两组分布之间

找到一个能把相似对和不相关对分开的分界点,就是你的阈值。对于中文问答,0.80--0.85 通常是合理起点,但必须用你自己的数据验证。

Semantic Cache 的真正价值:在 FAQ 量大、用户问法多变的场景(比如客服系统),它能大幅降低 LLM 调用次数。但它的效果完全取决于阈值校准------这是部署前的必做工作,不是设个默认值就能用的。


优化四:异步批量 Embedding

原理:顺序 embed N 条文本 = N 次网络往返。批量 embed N 条文本 = 1 次网络往返,服务端并行处理。

python 复制代码
import asyncio
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(...)

# 顺序(慢):每条文本一次 API 调用
sequential = [embeddings.embed_query(text) for text in texts]

# 批量异步(快):一次 API 调用,所有文本一起发
async def embed_batch(texts):
    return await embeddings.aembed_documents(texts)

batch = asyncio.run(embed_batch(texts))

实验结果

12 条文本:

makefile 复制代码
顺序嵌入(逐条):    830ms
异步批量嵌入(一次): 289ms
加速比:              2.87×

同样的向量,少了 11 次网络往返。向量完全一致(余弦相似度 > 0.9999)。

在 RAG 流程中的应用

python 复制代码
# 批量索引文档(建库时用)
async def index_documents_async(docs: list[Document]):
    texts    = [d.page_content for d in docs]
    vectors  = await embeddings.aembed_documents(texts)
    # 批量写入向量库
    ...

# 批量处理多用户并发查询(服务层用)
async def handle_batch_queries(questions: list[str]):
    vectors = await embeddings.aembed_documents(questions)
    # 并发检索
    results = await asyncio.gather(*[
        retriever.ainvoke(q) for q in questions
    ])
    return results

文档数越多,批量的收益越大。建库时把文档分批(每批 50--100 条)批量 embed,比逐条 embed 快 3--5 倍,取决于网络延迟。


四种优化的组合使用

python 复制代码
# 1. LLM 缓存(全局开启)
set_llm_cache(SQLiteCache(".llm_cache.db"))

# 2. Embedding 缓存(替换底层 embeddings)
store = LocalFileStore("./embedding_cache/")
embeddings = CacheBackedEmbeddings.from_bytes_store(
    underlying_embeddings=base_embeddings,
    document_embedding_cache=store,
    namespace=EMB_MODEL,
)

# 3. Semantic Cache(在 LLM 调用前检查)
semantic_cache = SemanticCache(embeddings, threshold=YOUR_CALIBRATED_THRESHOLD)

def query(question: str) -> str:
    # 先查 semantic cache
    cached = semantic_cache.get(question)
    if cached:
        return cached
    # 没有再走完整流水线
    docs    = retriever.invoke(question)
    answer  = llm.invoke(...)
    semantic_cache.set(question, answer)
    return answer

# 4. 批量操作时用 async(建库和并发请求)
vectors = asyncio.run(embeddings.aembed_documents(texts))

这四种优化互相正交,可以叠加。最高收益的组合:LLM 缓存 + Embedding 缓存 几乎零成本,应该默认开启。Semantic Cache 需要阈值校准,校准好后收益很大。批量 Embedding 仅在建库和高并发场景下值得专门处理。


实验汇总

markdown 复制代码
=====================================================================
  四种优化效果汇总
=====================================================================

  优化                    前            后          节省
  ─────────────────────────────────────────────────────────
  LLM 响应缓存          5057ms      0.8ms      99.98%  ✓ 强烈推荐
  Embedding 缓存(重建)  285ms      5.7ms      98%     ✓ 强烈推荐
  Embedding 缓存(更新)  8次调用     2次调用    75%     ✓ 强烈推荐
  Semantic Cache(0.85)  有效        阈值需校准  -      ⚠ 需校准后使用
  异步批量 Embedding     830ms      289ms      65%     ✓ 建库时推荐
=====================================================================

完整代码

代码已开源:

github.com/chendongqi/...

核心文件:

  • rag_performance.py --- 四个 benchmark 完整实现 + 报告生成

运行方式:

bash 复制代码
git clone https://github.com/chendongqi/llm-in-action
cd 21-rag-performance
cp .env.example .env
pip install -r requirements.txt
python rag_performance.py

小结

本文实现并测量了 RAG 的四种性能优化:

  1. LLM 响应缓存:最简单收益最大,一行代码开启,重复问题 5057ms → 0.8ms(6000× 加速)
  2. Embedding 缓存:相同文本不重复 embed,知识库更新时只 embed 变化的部分(8 次调用→2 次)
  3. Semantic Cache:概念正确,但阈值 0.85 在本次实验中全部 miss------说明阈值校准不可跳过,需要用真实数据测量相似度分布后再设值
  4. 异步批量 Embedding:12 条文本 2.87× 加速,文档数越多收益越明显

前三种优化针对的都是"重复计算"问题------同样的工作做第二遍是纯浪费。最后一种针对"串行等待"问题------可以并发的工作为什么要排队。两类问题的解法不同,但目标一样:让 RAG 在生产环境里真正跑得起来。


参考资料

相关推荐
Robot_Nav3 小时前
深度学习与强化学习面试八股文知识点汇总
人工智能·深度学习·强化学习
Z1Y492Vn3ZYD9et3B064 小时前
李彦宏:今年小龙虾明年可能螃蟹,AI的杀手级产品还没定型
人工智能
啊哈哈121384 小时前
系统设计复盘:为什么 Agent 的 ReAct 循环必须内嵌确定性保护层——以 FitMind 健康助手的路由与步骤控制为例
人工智能·python·react
@蔓蔓喜欢你4 小时前
数据可视化入门:让你的数据说话
人工智能·ai
2401_832298104 小时前
破解智能体幻觉难题,OpenClaw思维链重构,夯实工业级执行可靠性
人工智能
沪漂阿龙4 小时前
面试题详解:检索链路设计全攻略——RAG 检索架构、查询理解、多路召回、混合检索、Rerank、上下文构造与评估闭环
大数据·人工智能·架构
金融小师妹4 小时前
基于AI通胀预期模型与美元流动性监测框架的黄金6周新低行分析:美元五连涨周期下贵金属定价机制重构研究
大数据·人工智能·重构·逻辑回归·线性回归
gaosushexiangji5 小时前
DIC系统推荐:基于千眼狼三维数字图像相关的无人机旋翼疲劳试验全场应变与位移测量
人工智能·算法
智慧医养结合软件开源5 小时前
智慧养老系统医生管理模块:专业赋能,筑牢老人诊疗安全防线
大数据·人工智能·安全·生活