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% ✓ 建库时推荐
=====================================================================
完整代码
代码已开源:
核心文件:
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 的四种性能优化:
- LLM 响应缓存:最简单收益最大,一行代码开启,重复问题 5057ms → 0.8ms(6000× 加速)
- Embedding 缓存:相同文本不重复 embed,知识库更新时只 embed 变化的部分(8 次调用→2 次)
- Semantic Cache:概念正确,但阈值 0.85 在本次实验中全部 miss------说明阈值校准不可跳过,需要用真实数据测量相似度分布后再设值
- 异步批量 Embedding:12 条文本 2.87× 加速,文档数越多收益越明显
前三种优化针对的都是"重复计算"问题------同样的工作做第二遍是纯浪费。最后一种针对"串行等待"问题------可以并发的工作为什么要排队。两类问题的解法不同,但目标一样:让 RAG 在生产环境里真正跑得起来。