RAG 怎么控制 token 成本:检索精度与上下文长度的平衡之道
RAG(检索增强生成)是目前最主流的大模型落地架构之一,但很多团队在上线后发现成本远超预期。原因在于 RAG 的成本不只是”生成”的那一次 API 调用,检索阶段的 Embedding 调用和注入上下文的长度同样是重要成本来源。
我见过一个真实案例:某团队的客服 RAG 系统上线两周后,账单从预估的每天几十块涨到了小四位数,运维半夜被告警吵醒还以为是被刷单了。排查了两天才发现,问题根本不在生成模型本身——他们图省事把 top-k 设成了 20,每个文档块又切得很大(接近 1,000 token 一块),一次问答光是塞进 prompt 的检索结果就有近 2 万 token,其中真正跟用户问题相关的内容可能不到十分之一,剩下的全是陪跑的噪声,而这些噪声一样要按 token 计费。这也是为什么很多团队一上 RAG,成本曲线就会跟着检索配置一起失控:生成模型只是照单全收,账单涨在了你根本没盯着看的检索环节。
RAG 系统的三层成本
| 阶段 | 产生成本的操作 | 频率 |
|---|---|---|
| 索引阶段 | 文档 Embedding(离线批量) | 一次性 + 增量更新 |
| 检索阶段 | 查询 Embedding(在线) | 每次用户请求 × 1 |
| 生成阶段 | 检索到的文档块作为 context 注入 LLM | 每次用户请求 × top-k |
其中生成阶段的注入上下文通常是最大的成本来源:如果每次检索返回 top-5 个 512-token 的文档块,每次请求就多出约 2,500 token 的输入。这笔账放大看更直观:如果你的应用日均有 5 万次问答请求,光这一项,一天就要多吃掉 1.25 亿 token 的输入量——这还没算生成模型自己产出回答要花的 token。很多团队盯着”生成模型单价贵不贵”去比价,却漏算了这部分隐藏在检索配置里的输入量,等账单出来才后知后觉。
控制 Embedding 成本
索引阶段:批量 + 一次性
- 文档 Embedding 属于一次性离线任务,用 Batch API 处理可享约 50% 折扣
- Embedding 模型单价远低于生成模型(通常低 10–100 倍),不是成本重点
- 只对更新的文档做增量 Embedding,避免全量重算
Batch API 便宜的原因也值得说一句:它把你的请求丢进一个非实时的处理队列,服务商可以在算力空闲的时段统一调度、攒批处理,所以能给折扣,代价是通常要等到一个完成窗口(常见是提交后 24 小时内完成,具体以官方文档为准)才能拿到结果。这就决定了它只适合离线的索引构建、全量重建这类不着急的任务——如果你的业务是”用户上传文档后马上要能被检索到”,Batch API 的延迟会直接拖垮体验,这种场景老老实实用同步 Embedding 接口,成本上多花点但换来即时可用。
检索阶段:查询 Embedding 复用
from functools import lru_cache
@lru_cache(maxsize=1000) # 缓存最近 1000 个查询的 Embedding
def get_query_embedding(query: str) -> list[float]:
return embedding_client.embed(query)
同一用户在同一会话中重复问类似问题时,缓存命中率可达 30–50%。
这段代码看着简单,上生产前有两个坑得先踩明白:
lru_cache是进程内存缓存,只在当前 Python 进程里生效。如果你的服务是用 Gunicorn/Uvicorn 起了 4 个 worker 进程,实际上是 4 份互不相通的缓存各自为战,真实命中率会比单进程测试时低不少。想要跨进程共享,得换成 Redis 之类的外部缓存,用归一化后的查询文本(去掉首尾空格、统一大小写、去掉标点差异)做 key,否则”什么是RAG”和”什么是 RAG?“会被当成两个不同的查询,缓存直接失效。- 缓存没有过期机制,
maxsize=1000满了之后会按最近最少使用(LRU)规则淘汰旧条目,这对查询场景一般没问题,但如果你的文档库频繁更新、同一个查询在不同时间点应该检索到不同内容,就不能无脑缓存查询 Embedding——这时候该加的是缓存失效逻辑,而不是任由旧结果一直命中。
控制生成阶段的上下文成本
这是 RAG 成本优化的重点。
策略一:精确控制 top-k
不要默认用 top-5 或 top-10,找到你的最小有效 k:
# 从 top-3 开始测试,逐步增加,观察回答质量何时不再提升
for k in [1, 2, 3, 5, 8]:
results = vector_db.search(query, top_k=k)
answer = llm.generate(context=results, question=query)
quality_score = evaluate(answer) # 用你的评估逻辑
print(f"k={k}: 质量={quality_score}, context_tokens={count_tokens(results)}")
多数场景下 top-3 到 top-5 已经足够,超过之后质量边际提升极小,但成本线性增加。
注意这段循环是一次性的线下标定脚本,不是拿到生产环境里跑的东西——它对同一个 query 调用了 5 次 llm.generate(),本身就是一笔不小的测试成本,只应该在你搭建评估集、确定生产环境最终 k 值的阶段跑一次。evaluate() 这个函数怎么实现也有讲究:如果你手头有人工标注过”标准答案”的 golden set,可以用字符串匹配或者语义相似度打分;如果没有,很多团队会退而求其次用”LLM 当裁判”(把生成答案和参考答案一起丢给另一个模型打分),但这个裁判本身也要花 token,评估阶段的成本记得单独算,不要和线上成本混在一起看。
策略二:相关性分数过滤
不仅限制数量,还过滤质量差的结果:
results = vector_db.search(query, top_k=10)
# 只保留相关性分数高于阈值的结果
filtered = [r for r in results if r.score > 0.75]
# 最多保留 top-3,避免过滤后仍然太多
context_chunks = filtered[:3]
0.75 这个阈值不是万能常数,千万别从某篇博客抄来直接用在自己项目上。不同 Embedding 模型算出来的相似度分数分布差异很大——有的模型分数普遍集中在 0.60.9,有的模型同样相关的内容却只能打到 0.40.5,阈值必须针对你实际用的 Embedding 模型重新标定,方法很简单:拿几十条已知”相关”和”不相关”的问答对,看看两类分数分别落在什么区间,取中间值。
还有一个更容易被忽略的坑:如果 filtered 过滤完是空的怎么办?很多实现直接把空列表传给 llm.generate(),模型拿不到任何 context,但它不会老实说”我不知道”,而是会开始凭训练数据里的记忆编答案——这就是 RAG 场景下典型的幻觉来源,而且比不用 RAG 更隐蔽,因为团队会误以为”检索到的内容”支撑了这个回答。生产环境一定要加保底逻辑:过滤后为空就适当放宽阈值重试一次,或者直接让系统回答”没有找到相关内容”,而不是让模型硬编。
策略三:分块大小优化
| 分块策略 | token 消耗 | 适用场景 |
|---|---|---|
| 大块(1,024 token) | 高,但单次检索就够 | 内容连贯性强的文档 |
| 小块(256 token) | 低,但可能需要更多块 | FAQ、结构化条目 |
| 父子分块 | 中等,精准检索后扩展上下文 | 通用推荐方案 |
父子分块方案: 用小块做检索(高精度),命中后取其父块(完整上下文)注入 LLM,在精度和 token 消耗之间取得平衡。
实现上这个方案容易被低估工作量。它不是简单调个参数就完事,你需要:切分文档时用递归字符切分器先切出较大的父块,再对每个父块二次切分成检索用的子块,并给子块打上 parent_id 元数据一起存进向量库;命中子块之后,拿这个 parent_id 去另一个存储(一张 SQLite 表或者一个 KV 存储都行)里查出父块的完整文本再注入 LLM。也就是说向量库本身通常只适合存”用来检索”的小块,完整的父块内容得单独维护一份文档存储——这是很多团队第一次实现父子分块时漏掉的一环,误以为向量库能顺带存好所有层级的内容。
策略四:重排序(Reranking)后再截断
用轻量 Reranker 模型对检索结果重新打分,只取重排后前 N 名:
raw_results = vector_db.search(query, top_k=20) # 粗检索多取一些
reranked = reranker.rerank(query, raw_results) # 重排,更准确
final_context = reranked[:3] # 只注入前 3 名
Reranker 的成本极低(通常是本地模型或极便宜的 API),但能显著提高检索精度,让每个注入的块都真正有用。
本地模型和 API 两种部署方式怎么选,看你团队的实际情况:
| 方案 | 延迟 | 成本 | 部署门槛 | 适合谁 |
|---|---|---|---|---|
| 本地跑开源 cross-encoder | 低(毫秒级,同机房) | 只有算力成本,无调用费 | 需要自己管模型和推理服务 | 有 GPU/CPU 资源、请求量大到 API 调用费划不来的团队 |
| 调用托管 Reranker API | 中(含网络往返) | 按调用量计费,通常很便宜 | 几乎零门槛,接个接口就行 | 快速验证效果、请求量不大、不想自建推理服务的团队 |
用 API 版本的话有一点要提前防:Reranker 调用也是你请求链路里的一环,如果它本身触发了服务商的限速返回 429,你的整个问答请求也会跟着卡住甚至失败。生产环境里给这类内部调用也配上超时和重试退避,不要因为”它只是个辅助步骤”就掉以轻心。
策略五:上下文压缩
不注入整个文档块,而是先提取与问题相关的句子:
def extract_relevant_sentences(chunk: str, question: str, llm) -> str:
prompt = f"从以下文本中提取与问题最相关的 1-3 句话。\n问题:{question}\n文本:{chunk}"
return llm.generate(prompt, model="gpt-4o-mini", max_tokens=100) # 用轻量模型
# 对每个检索到的块先压缩,再注入
compressed_context = [extract_relevant_sentences(c, query, llm) for c in raw_chunks]
这一步本身是要花钱的:每个 chunk 都单独调一次 LLM,哪怕用的是便宜的轻量模型,请求数量和延迟也会跟着涨上去——如果你的 top-k 是 3,这里就凭空多了 3 次串行(或并行)的额外调用。什么时候值得用这招给出个简单判断:只有当原始 chunk 明显偏大(比如超过 400~500 token)、且压缩后能省下的 token 明显多于这次额外调用花掉的 token 时才划算;如果你的分块本来就控制在 200 token 左右,这一步的调用成本很可能反而超过它省下来的部分,属于典型的”为了优化而优化”,这种情况不如把预算花在把 top-k 调低或者上 Reranker 上。
典型优化效果对比
| 优化配置 | 每次请求 context token(估算) |
|---|---|
| 默认 top-10 × 512 token 块 | ~5,120 token |
| 优化后 top-3 × 256 token 块 + 相关性过滤 | ~600–800 token |
| 父子分块 + Reranker top-3 | ~768 token(更准确) |
成本可降至默认配置的 15–20%,且回答质量通常不下降甚至提升(因为噪声更少)。
进阶:批量导入时的并发与限流
前面说的都是单次问答的成本优化,但如果你要做的是批量导入历史文档(比如一次性把 10 万篇文章都建好索引),会遇到另一类完全不同的坑。
真实现象是这样的:批量导入跑起来前几百个请求都很正常,但从某个时间点开始,日志里持续报错,embedding_client.embed() 抛出 RateLimitError,HTTP 状态码是 429 Too Many Requests。根因很直接——你为了赶进度开了很高的并发(比如用多线程或者 asyncio.gather 一次性发出几百个请求),超过了 Embedding 服务商对你的账号设定的 TPM(每分钟 token 数)或 RPM(每分钟请求数)限制,服务商用 429 把你挡回来了。
修法也不复杂,分两步:一是加一个令牌桶或者信号量控制并发数,别让并发无限往上冲;二是给每次请求配上指数退避重试——第一次失败等 1 秒,连续失败就翻倍等待(2 秒、4 秒、8 秒……),设一个上限(比如最多等到 30 秒),超过重试次数上限再放弃并记录下来人工处理。这套组合拳做好之后,批量导入 10 万篇文档从”跑到一半就报错卡死”变成”稳定跑完,只是慢一点”,本质上是用时间换稳定性,而不是死磕更高的并发数硬冲限速红线。
另外,索引阶段的并发优化和线上问答阶段是两回事,不要混为一谈:批量导入追求的是”在限速范围内尽快跑完”,而线上问答请求追求的是”单次响应尽量快”,两者的限流策略和重试策略应该分开配置,用同一套参数很容易顾此失彼。
常见问题
减少 top-k 会导致”检索不到”吗?
如果检索质量好(Embedding 模型合适、分块合理),top-3 命中率通常已经足够。可以通过评估集(golden set)测量 Recall@k,确认降低 k 后召回率是否可接受。
Embedding 模型本身影响成本吗?
影响不大,Embedding API 单价很低。但 Embedding 质量直接影响检索精度,进而影响需要多少 top-k 才能找到答案——选好 Embedding 模型反而间接降低生成成本。
RAG 成本和纯长上下文方案哪个便宜?
多数场景下 RAG 更便宜:把 100 万 token 的文档库全部塞入长上下文模型费用极高,RAG 只取相关的几百 token 注入。但对于需要全局推理的任务(如”分析整本书的主题”),长上下文有其不可替代性。
Reranker 一定要加吗,能不能跳过这一步直接上生产?
小规模、文档库结构简单(比如都是短 FAQ)的场景可以先跳过,直接用向量检索的相似度排序,把 top-k 控制小一点就够用。但如果你的文档库量级上到几万篇以上、内容领域交叉多,向量检索本身的排序质量会明显下降,这时候 Reranker 带来的精度提升往往能让你把 top-k 从 8~10 压到 3,成本和质量是同时改善的,不是纯粹的额外开销。建议先跑一版没有 Reranker 的基线,测出质量分和成本,再加上 Reranker 对比一次,用数据决定要不要留着它,而不是凭感觉。
批量导入时具体该怎么设并发数?
没有放之四海而皆准的数字,因为每个账号的限速额度不同(新账号、低等级账号的限速通常比用了一段时间、消费达标后自动升级的账号低不少,具体额度以服务商后台面板为准)。实操上建议从一个保守的并发数(比如 5~10)开始跑,观察是否触发 429,如果全程没有报错再逐步往上调,找到你账号当前额度下的稳定并发上限,而不是一上来就猜一个很大的数字硬冲。
延伸阅读:
- 计费原理总览:大模型 token 计费完全指南
- 全面优化清单:大模型 API 成本优化 10 招
- 上下文压缩技巧:上下文压缩与裁剪
- 批量处理省钱:Batch API 批量调用省钱指南
- Token 用量估算:Token 计算器
- token 成本专题:token 成本 Hub