优化检索增强生成(RAG)系统的各个组件是重要的一步。然而,要真正在生产环境中发挥 RAG 的全部效能,我们必须考虑端到端性能。实现此优化的一个有效方法是巧妙地实施缓存。缓存是指存储耗时操作的结果,并在相同的输入再次出现时重复使用这些结果。在 RAG 管道中,会发生多项复杂计算和数据查询,策略性缓存可以大幅降低延迟,减轻计算负荷,甚至通过减少对外部 API 的调用或密集型模型推理来降低运营成本。本节介绍适用于 RAG 管道不同阶段的各种缓存策略。我们将研究缓存什么、在哪里实现这些缓存,以及相关的权衡,特别是关于数据新鲜度和系统复杂性。为什么缓存对 RAG 很重要在介绍具体技术之前,我们先明确为什么缓存对 RAG 系统如此有利:减少延迟:检索数据、生成嵌入以及运行大型语言模型(LLM)推理都非常耗时。缓存它们的输出可以使后续相同或类似的请求几乎即时得到响应。提高吞吐量:通过减少许多请求的计算负荷,系统可以使用相同的硬件资源处理更多的并发用户。节省成本:嵌入生成和 LLM 推理通常依赖于按使用付费的 API 或需要大量计算资源。缓存减少了这些操作的次数,从而直接节省了成本。减轻下游系统负荷:缓存减轻了向量数据库、嵌入模型端点和 LLM API 的压力,这可以提高它们的稳定性并避免速率限制问题。然而,缓存并非万能。主要难题是缓存失效:确保缓存中不提供陈旧或过时信息。这需要仔细考虑数据的易变性以及应用程序对潜在陈旧信息的容忍度。RAG 管道中的缓存层一个典型的 RAG 管道包含多个可以引入缓存的阶段。我们来研究这些层:digraph RAG_Pipeline_Caching { rankdir=LR; node [shape=box, style=rounded, fontname="sans-serif", margin=0.2]; edge [fontname="sans-serif"]; UserQuery [label="用户查询"]; QueryCache [label="查询缓存\n(完整响应)", shape=cylinder, style=filled, fillcolor="#ffc9c9"]; EmbeddingGen [label="嵌入生成\n(查询)"]; EmbeddingCache_Query [label="嵌入缓存\n(查询)", shape=cylinder, style=filled, fillcolor="#eebefa"]; VectorDB [label="向量数据库\n(相似性搜索)"]; RetrievedDocsCache [label="检索文档缓存", shape=cylinder, style=filled, fillcolor="#bac8ff"]; ReRanker [label="重排模型", optional=true]; ReRankerCache [label="重排器输出缓存", shape=cylinder, style=filled, fillcolor="#99e9f2", optional=true]; LLM [label="LLM 生成"]; LLMCache [label="LLM 响应缓存", shape=cylinder, style=filled, fillcolor="#b2f2bb"]; FinalResponse [label="最终响应"]; UserQuery -> QueryCache; QueryCache -> FinalResponse [label="命中"]; QueryCache -> EmbeddingGen [label="未命中"]; EmbeddingGen -> EmbeddingCache_Query; EmbeddingCache_Query -> VectorDB [label="命中/未命中"]; // 简化 EmbeddingCache_Query -> EmbeddingGen [label="未命中 (生成内部)"]; VectorDB -> RetrievedDocsCache; RetrievedDocsCache -> ReRanker [label="如果无重排器则命中/未命中至LLM"]; RetrievedDocsCache -> VectorDB [label="未命中 (检索内部)"]; // 可选的重排路径 subgraph cluster_reranker { label="可选的重排"; style=dashed; ReRanker -> ReRankerCache; ReRankerCache -> LLM [label="命中/未命中"]; ReRankerCache -> ReRanker [label="未命中 (重排器内部)"]; } // 如果无重排器或重排器未命中时的默认路径 RetrievedDocsCache -> LLM [style=invis]; // 确保 LLM 位置正确 VectorDB -> LLM [style=invis]; // 确保 LLM 位置正确 LLM -> LLMCache; LLMCache -> FinalResponse [label="命中"]; LLMCache -> LLM [label="未命中 (LLM 内部)"]; // 流程连接 UserQuery -> EmbeddingGen [style=dotted, constraint=false]; // 未命中时绕过查询缓存 EmbeddingGen -> VectorDB [style=dotted, constraint=false]; // 未命中时绕过嵌入缓存 VectorDB -> ReRanker [style=dotted, constraint=false]; // 未命中时绕过检索文档缓存 ReRanker -> LLM [style=dotted, constraint=false]; // 未命中时绕过重排器缓存 LLM -> FinalResponse [style=dotted, constraint=false]; // 未命中时绕过LLM缓存 }示意图,展示了 RAG 管道中可能的缓存点。每个圆柱体代表一个缓存存储。1. 完整请求/响应缓存这是最外层的缓存。缓存内容:将最终生成的响应映射到确切的传入用户查询。要点:通常是标准化的用户查询字符串。标准化可能包括转换为小写、删除多余空格或统一标点符号。优点:如果重复执行精确查询,则具有最高的延迟降低潜力。可以节省整个管道的成本和计算。缺点:仅对相同查询有效。措辞上的微小差异将导致缓存未命中。如果底层知识库或 LLM 的预期行为发生变化,则容易提供陈旧信息。实现:一个简单的键值存储(例如 Redis、Memcached)通常就足够了。CACHE_KEY = normalize(user_query) if CACHE_KEY in response_cache: return response_cache[CACHE_KEY] else: # 继续 RAG 管道流程 response = ... response_cache[CACHE_KEY] = response return response2. 嵌入缓存嵌入是文本的向量表示。生成它们涉及模型推理步骤。查询嵌入:缓存内容:用户查询的嵌入向量。标准化:标准化的用户查询。优点:减少与查询嵌入模型相关的延迟和成本,特别是当它是远程 API 时。缺点:如果查询嵌入模型是本地且速度非常快,则收益可能微不足道。文档嵌入:缓存内容:文档块的嵌入向量。这在数据摄取/索引阶段尤为重要,但如果文档是动态嵌入的,也可能相关。唯一标识符:文档块的唯一标识符或块内容本身(哈希值)。优点:在大型文档集的初始索引和重新索引过程中可观地节省时间和成本。缺点:如果文档更新,需要强力失效机制。实现:键值存储是合适的。如果直接存储大型嵌入向量,请确保缓存能够高效处理;如果嵌入在其他地方管理,则存储引用。3. 检索文档缓存在查询嵌入用于搜索向量数据库后,将检索出一组相关文档块。缓存内容:给定查询嵌入或标准化查询所检索到的文档 ID 列表(可能包括其简洁内容或元数据)。键:查询嵌入(或其哈希值)或标准化查询字符串。使用嵌入作为键可以比精确字符串匹配更好地捕捉语义相似性。优点:如果语义相似的查询很常见,可以显著加快检索步骤。减轻向量数据库的负荷。缺点:如果重排阶段或 LLM 严重依赖检索上下文中的细微差异,此缓存可能效果不佳或需要仔细的失效处理。“相似查询”的缓存命中定义需要仔细调整。实现:键值存储。值将是文档标识符列表或预取片段。4. 重排器输出缓存如果您的 RAG 管道包含重排步骤(例如,使用交叉编码器重新评分最初检索到的文档),其输出也可以被缓存。缓存内容:对于给定来自初始检索阶段的文档 ID 输入列表和原始查询,其重排后的文档 ID 列表(及分数)。复合键:一个复合键,包括输入文档列表的哈希值和标准化查询或查询嵌入。优点:重排器可能计算密集。缓存其输出可为重复输入节省此计算。缺点:重排器的输入(初始检索集)必须完全相同才能命中缓存。如果初始检索阶段经常为不同但相关的查询返回相同的候选集,则此层最有利。5. LLM 提示/响应缓存这涉及为给定提示(包括查询以及检索到的、可能已重排的上下文)缓存 LLM 生成的最终响应。缓存内容:LLM 生成的文本。提示:提交给 LLM 的完整提示的哈希值。此提示通常是根据用户查询和获取的上下文文档构建的复杂字符串。优点:直接降低 LLM 推理成本和延迟,这通常是管道中最重要的部分。缺点:由于检索到的上下文不同,提示可能非常长且高度可变,导致缓存命中率较低,除非为相似查询检索到完全相同的上下文。提示的庞大尺寸也可能成为生成和存储的考量因素。实现:将(查询 + 上下文)的哈希值作为键存储。值是 LLM 的响应。# 简化示例 def get_llm_response_with_cache(query, context_docs, llm_cache): prompt = f"Context: {context_docs}\n\nQuestion: {query}\n\nAnswer:" prompt_hash = hashlib.sha256(prompt.encode()).hexdigest() if prompt_hash in llm_cache: return llm_cache[prompt_hash] else: response = llm.generate(prompt) # 实际的 LLM 调用 llm_cache[prompt_hash] = response return response缓存失效策略只有当缓存的数据合理地保持最新时,缓存才有用。陈旧数据可能导致不正确或不相关的响应。存活时间 (TTL):最简单的策略。每个缓存条目都分配一个过期时间。优点:易于实现。缺点:可能会过早地淘汰有用数据,或者在 TTL 到期前提供陈旧数据。选择合适的 TTL 可能具有挑战性,并且通常是一种权衡。事件驱动失效:当底层数据发生变化时,缓存条目被主动移除或更新。示例:如果您的知识库中的文档被更新,任何依赖该文档旧版本的缓存响应或检索文档集都应该失效。优点:确保数据新鲜度。缺点:实现更复杂。需要一种机制来跟踪缓存项和源数据之间的依赖关系。最近最少使用 (LRU) / 最不常用 (LFU):这些是当缓存达到其大小限制时使用的淘汰策略。LRU 丢弃最长时间未访问的数据。LFU 丢弃访问次数最少的数据。优点:通过优先处理频繁或最近访问的数据,有助于有效管理缓存大小。缺点:本身不解决数据陈旧问题,更多是关于管理有限的缓存空间。直写式缓存:数据同时写入缓存和后端存储。这确保了缓存一致性,但增加了写入操作的延迟。这更适用于同时也是某些数据真实来源的缓存,对于 RAG 响应缓存来说不太常见,但如果文档嵌入缓存直接更新,则可能适用。对于 RAG 系统,组合使用通常效果最好:TTL 用于通用过期,同时对关键数据更新(例如,当文档语料库发生显著变化时)采用事件驱动的失效。选择缓存存储缓存技术的选择取决于规模、持久性要求和现有基础设施等因素:内存缓存(例如,Python 字典,cachetools 库):优点:访问速度极快。对于单进程应用程序来说很简单。缺点:易失性(进程重启时数据丢失)。仅限于单个进程的内存,不适用于分布式系统。分布式内存缓存(例如,Redis,Memcached):优点:速度非常快。可在多个应用程序实例/服务之间共享。Redis 提供持久化选项和各种数据结构。缺点:需要单独的基础设施来管理。访问存在网络延迟,尽管通常很低。数据库支持的缓存:使用标准数据库(SQL 或 NoSQL)作为缓存。优点:数据持久。可以借助现有数据库基础设施。缺点:与内存解决方案相比,访问时间较慢。对于大多数生产 RAG 系统,像 Redis 这样的分布式内存缓存是常见且有效的选择,因为它在速度、可扩展性和功能之间取得了平衡。衡量缓存效果为了评估缓存策略的影响,请监控以下指标:缓存命中率:从缓存中响应的请求百分比。 $$ \text{缓存命中率} = \frac{\text{缓存命中数}}{\text{缓存命中数} + \text{缓存未命中数}} $$延迟降低:比较有缓存和无缓存情况下的平均响应时间,或缓存命中与缓存未命中情况下的平均响应时间。成本降低:追踪对付费 API(嵌入模型、LLM)调用次数的减少或计算使用量的降低。{ "data": [ { "x": [ "无缓存", "嵌入缓存", "LLM 缓存", "完整缓存策略" ], "y": [2500, 1800, 900, 500], "type": "bar", "marker": { "color": [ "#ff6b6b", "#be4bdb", "#228be6", "#40c057" ] }, "name": "平均延迟 (ms)" } ], "layout": { "title": { "text": "缓存层对 RAG 系统延迟的影响" }, "xaxis": { "title": { "text": "实施的缓存策略" } }, "yaxis": { "title": { "text": "平均延迟 (ms)" } }, "height": 400 } }不同缓存层带来的延迟改善。“完整缓存策略”意味着多层缓存,例如查询、嵌入和 LLM 缓存。实际考虑与最佳实践缓存设计:标准化:对于基于查询的键,对文本进行标准化(例如,转换为小写、删除标点符号、参数排序)以提高命中率。哈希:对于长键(如完整的 LLM 提示),使用快速且抗碰撞的哈希函数(例如 SHA-256)来创建更短、固定大小的键。粒度:决定是缓存细粒度结果(例如,单个嵌入)还是粗粒度结果(例如,最终 LLM 响应)。粗粒度缓存每次命中可节省更多,但命中率可能较低。序列化:确保存储在缓存中的对象能够高效地序列化和反序列化(例如,使用 JSON、如果使用 Redis 则对 Python 对象使用 Pickle,或专用二进制格式)。冷启动:注意“冷启动”问题,即空缓存不提供任何初始好处。如果可行,考虑用常见查询或项目预填充缓存。安全与隐私:如果缓存用户特定数据或个人身份信息(PII),请确保缓存存储具有适当的安全控制和访问限制。必要时加密敏感数据。错误处理:实现逻辑以优雅地处理缓存故障。如果缓存存储暂时不可用,系统应理想地回退到重新计算结果,尽管这可能会影响性能。迭代实现:首先识别 RAG 管道中最耗时或最常重复的操作(此处可借助于性能分析),并首先对这些操作实施缓存。然后,迭代地增加更多缓存层并衡量其影响。实施缓存既是一门科学,也是一门艺术。它需要了解您的特定应用程序的工作负荷模式、数据的易变性以及性能与新鲜度之间可接受的权衡。通过在 RAG 管道的各个层面策略性地应用缓存,您可以构建出不仅智能而且非常快速高效的系统,能够应对生产环境的需求。