Skip to content
RAG 应用工程化:数据更新、缓存策略与线上性能优化
1 RAG 应用工程化的核心链路
RAG 应用由离线数据管线和在线查询服务两个部分组成。离线数据管线负责将原始文档转换为可检索的向量索引;在线查询服务负责把用户问题转换为检索请求,在检索结果的基础上调用 LLM 生成回答。
两个部分关注的问题不同。数据管线关注数据新鲜度、任务幂等性和索引重建成本;查询服务关注延迟、缓存命中率和吞吐。工程化工作大多发生在两个部分之间:数据更新如何安全地下发到在线索引,缓存如何在数据变化后保持一致。
原始文档
│ 解析、切片、Embedding
▼
向量集合 ──别名/版本──► 在线查询服务 ──► LLM
▲
缓存层(语义缓存、检索缓存)数据更新发生在向量集合这一侧,查询流量通过明确的版本或别名指向某一代索引。缓存层位于查询服务之前,用于减少重复计算。
2 文档解析、文本切片与向量化
文档解析是数据管线的第一段。PDF、DOCX、PPTX、HTML、TXT 等格式需要先统一转换为纯文本。Unstructured IO 是多格式解析的常见方案之一,能够把各类原始文档解析为可供后续处理的文本内容 [1]。
文本切片把长文档切成若干语义片段。切片粒度直接影响检索质量:太大会混入无关信息,太小会丢失上下文。固定长度切分与递归字符切分是两种基本策略 [1]。
向量化是最后一步:将切片文本送入 Embedding 模型,得到固定维度的向量,写入向量数据库。这里不展开 Embedding 模型选型,只假定每个切片对应一个向量。
2.1 切片 ID 与元数据约定
切片入库时,除了向量本身,还需要主键和元数据。主键用于后续的更新与删除,元数据用于过滤和溯源。
下面是一种示例约定,不是所有向量数据库都强制要求的格式:
typescript
interface Chunk {
id: string; // 示例约定:`${docId}:${chunkIndex}`
docId: string; // 所属文档
chunkIndex: number; // 在文档中的位置编号
content: string;
embedding: number[];
metadata: {
source: string; // 来源链接或文件路径
revision: string; // 文档内容修订 hash,用于检测文档是否变化
page?: number; // 页码,适用于 PDF/DOCX
};
}在上述约定中,revision 是内容版本的标记。当原始文档内容变化时,数据管线会计算新的 revision,并在更新向量数据时用新值覆盖旧值。在线服务可以通过比对 revision 判断某个切片是否已经过期。
切片 ID 中的 chunkIndex 用于保持切片顺序;docId 用于在删除文档时按该字段一次删除全部切片。使用长 ID 时,应确认所选向量数据库对主键长度和字符集的限制,并按需要做哈希化。
这里没有把主键规则定义为一个确定的标准,因为不同数据库的主键语义有差异。选择上述约定的目的是让后续删除、更新和缓存校验都能复用同一组字段。
3 向量索引的数据更新模式
向量索引更新有以下三种基础模式:
- 全量重建:清空集合并重新写入全部文档。
- 增量追加与覆盖(Upsert):按主键写入新向量,覆盖旧向量。
- 按元数据删除:按过滤条件删除指定向量。
实际项目中通常组合使用。
3.1 全量重建
全量重建指清空集合,重新写入全部文档。触发条件通常包括:
- 首次导入数据
- Embedding 模型升级,所有旧向量需要重新计算
- 索引参数变更,现有索引结构不再适用
以腾讯云向量数据库接口为例,清空集合由 /collection/truncate 提供,保留集合配置,清除全部数据;/index/rebuild 负责重建索引,可清除无用索引数据、修复损坏索引、优化索引结构 [3]。
全量重建的成本与数据量相关。大规模数据集的重建耗时通常较长 [4],因此一般放在低峰期执行,并通过索引版本切换来降低对在线服务的影响。
3.2 增量追加与覆盖(Upsert)
增量更新用于新增或修改文档。Upsert 是“存在则更新,不存在则插入”的写入语义。AnalyticDB for PostgreSQL 的向量接口提供 UpsertCollectionData 来完成这一操作 [5]。
typescript
await vectorStore.upsert([
{
id: 'doc-123:0',
vector: embeddingOfChunk,
metadata: { docId: 'doc-123', revision: 'a3f9c2' }
},
// ...
]);上面的 vectorStore.upsert 是抽象写法,实际项目中应替换为所选数据库提供的写入接口。
注意点: Upsert 的具体语义因数据库而异。选型时应在测试环境验证:按同一主键写入两次后,集合中保留的是最新记录,还是两条记录都会存在。
3.3 按元数据删除
删除文档时,按 docId 过滤是一种常用做法。对应接口一般是 delete(filter) 或 DeleteCollectionData [5]。
typescript
await vectorStore.delete({
filter: { docId: 'doc-123' }
});删除大量数据时,可采用分批删除,避免长时间持有锁或占用过多 IO。
3.4 索引版本切换
全量重建不会直接覆盖在线索引,而是先写入新集合,构建索引,然后通过别名将查询流量切到新集合。这种模式可以避免在线查询在重建过程中访问到中间状态。
在腾讯云向量数据库中,可通过 /alias/set 将流量切到新版本,通过 /alias/delete 删除旧版本 [3]。
documents_v1 ──► 写入新数据 ──► documents_v2
▲
别名切换版本切换完成后,旧集合可以异步销毁,也支持快速回滚。
版本切换同时为缓存失效提供了信号:数据集合版本号变化后,下游缓存可以据此切换命名空间。
4 源数据变更捕获与同步流水线
数据管线的上游是源数据,例如业务数据库、内容管理系统或云存储。源数据的增删改需要被同步到向量集合。
4.1 变更检测
源数据变更检测没有唯一解决方案,常用方式包括以下三种:
- 定时轮询:读取源表中
updated_at大于上次同步时间的记录。 - 事件监听:通过 Binlog/CDC 组件订阅数据库变更事件,适合低延迟同步。
- 手动触发:管理员在管理端手动发布新文档或触发全量重建。
轮询实现简单,但要接受轮询间隔带来的同步延迟。CDC 实时性更好,但需要额外维护一套基础设施。
4.2 同步流水线的基本步骤
同步流水线处理单个文档的典型流程:
typescript
async function syncDocument(raw: RawDocument): Promise<void> {
// 1. 读取原始文档内容
const text = await parseDocument(raw);
// 2. 切分为 Chunk
const chunks = splitText(text, { chunkSize: 512, overlap: 64 });
// 3. 计算 revision
const revision = hash(text);
// 4. 批量 Embedding
const embeddings = await embedChunks(chunks);
// 5. 删除旧切片(按 docId)
await vectorStore.delete({ filter: { docId: raw.docId } });
// 6. 写入新切片
await vectorStore.upsert(chunks.map((chunk, i) => ({
id: `${raw.docId}:${i}`,
vector: embeddings[i],
metadata: { docId: raw.docId, revision }
})));
}注意点: 执行顺序对数据一致性有影响。先删除再写入可以避免新旧切片同时存在造成的检索混合。但跨两个步骤的操作不是原子的:如果写入失败,会短暂出现文档缺失;反过来先写入再删除,又可能造成短暂重复。
一种常见做法是引入补偿任务:后台检查每个文档的切片数量与 revision 是否匹配,发现不匹配时重新执行同步。具体的补偿频率和范围需要根据数据量设计。
4.3 幂等性
消息队列场景下,同一个“文档更新事件”可能被消费多次。上面的流程是幂等的:相同的输入导致同样的删除和写入,重复执行不会产生额外脏数据。
typescript
async function onMessage(msg: { docId: string; updatedAt: number }) {
const raw = await fetchDoc(msg.docId);
const current = await fetchDocFromVectorStore(msg.docId);
if (current && current.updatedAt >= msg.updatedAt) {
// 已处理过或消息乱序,丢弃
return;
}
await syncDocument(raw);
}这要求源数据提供 updatedAt(或等价的时间戳),用于判断乱序消息。
5 检索链路:混合检索、重排序与过滤
在线查询服务的检索链路由过滤、向量检索、全文检索和重排序组成。
过滤是在检索前或检索后应用的条件,例如 tenantId、docType、publishAt。向量检索使用查询的 Embedding 在向量集合中寻找相似切片。全文检索使用关键词匹配,在专有名词和精确匹配场景下更有优势。两者结合即混合检索。
一个典型的检索流程:
用户 query
│
▼
embedding 查询 ──► 候选切片 A(按向量相似度)
│
query 关键词查询 ──► 候选切片 B(按全文相关性)
│
▼
合并、去重、打分
│
▼
重排序模型(Rerank)
│
▼
取前 N 个切片送入 LLM候选切片数量是控制质量与延迟的重要参数。不同产品中该参数名不同,常见的有 topK、limit、top_k,含义都是“返回相似度最高的前 K 条”。数量过少会漏掉关键内容,过多会让进入 LLM 的上下文变长,增加生成延迟。
重排序通过专门的语义排序模型对候选切片重新打分。经过重排序后,最终进入 LLM 的切片数量可以少于向量检索的候选数量,从而在质量与 token 成本之间取得平衡。
注意点: 重排序会增加额外延迟,量级通常在百毫秒级。对延迟敏感的场景,可以只在候选切片的不确定性较高时启动重排序,但这会增加逻辑复杂度。
6 多级缓存架构
缓存层位于查询服务内部,目的是减少对向量数据库、Embedding 服务和 LLM 的重复调用。RAG 场景中,缓存按缓存内容分为四个层级:
| 缓存层级 | 缓存内容 | 命中条件 |
|---|---|---|
| 语义缓存 | 用户问题 → LLM 最终回答 | 问题语义相似度超过阈值 |
| 检索结果缓存 | 规范化查询 → 切片列表 | 查询键完全一致 |
| LLM 响应缓存 | 规范化查询 + 上下文 → 生成结果 | 查询键完全一致且数据版本一致 |
| Embedding 缓存 | 文本 → 向量 | 文本完全一致 |
6.1 语义缓存
语义缓存面向“用户提出了语义相同的问题”的场景。它将用户 query 做 Embedding,在缓存存储中执行相似度搜索;当最高相似度超过预设阈值时,直接返回缓存中的答案 [2]。
GPTCache 是这一思路的参考实现。它由 LLM、Embedding Generator、Cache Storage(默认 SQLite)和 Vector Store 组成。禁用 Embedding 后,GPTCache 会退化为关键字精确匹配缓存 [2]。
注意点: 相似度阈值必须通过业务测试集标定。阈值过高,缓存几乎不命中;阈值过低,会把“看起来差不多但答案不同”的问题错误匹配。语义相似不等于答案正确,对事实类问题的缓存尤其需要谨慎。
6.2 检索结果缓存
检索结果缓存保存“查询条件 → 检索结果”的映射。命中的请求直接拿到切片列表,跳过向量检索和重排序,只保留后续的 LLM 生成。
检索结果缓存的优点是失效判定比语义缓存简单:规范化后的查询和过滤条件完全一致才命中。它不缓存 LLM 最终回答,因此不会把错误的生成结果复用给语义不同的问题。
6.3 LLM 响应缓存
LLM 响应缓存直接缓存最终生成结果。对于完全相同的查询,在温度参数为 0 的情况下,可以返回相同回答。
注意点: LLM 响应缓存必须绑定数据版本,并在版本变化时失效;否则,文档更新后,旧回答可能引用已删除的内容。
6.4 Embedding 缓存
Embedding 缓存以“文本的哈希值”为键,保存文本对应的向量。数据管线批量处理时,如果多个文档包含相同文本,可以复用向量。这个缓存风险最低,收益也有限,因为大部分 RAG 场景中文档重复度不高。
7 缓存键设计与失效一致性
缓存命中率与缓存键设计直接相关。检索结果缓存和 LLM 响应缓存使用精确键,设计目标是在“足够区分不同请求”和“提高复用率”之间取得平衡。
7.1 检索结果缓存键
缓存键需要覆盖以下变量:规范化后的查询文本、过滤条件、候选切片数量、索引版本。下面的代码演示了缓存键的构造方式:
typescript
function buildRetrievalCacheKey(input: {
query: string;
filters: Record<string, unknown>;
candidateCount: number;
datasetVersion: string;
}): string {
const canonical = JSON.stringify({
q: input.query.trim().replace(/\s+/g, ' ').toLowerCase(),
f: stableStringify(input.filters),
n: input.candidateCount
});
const digest = hashDigest(canonical);
return `retrieval:${digest}:v${input.datasetVersion}`;
}过滤条件必须做序列化排序(stableStringify),否则相同条件因键顺序不同会命中不同缓存条目。datasetVersion 是当前索引版本号,由数据管线在每次更新完成后递增。
7.2 增量更新下的失效策略
增量更新场景下数据持续变化,全部清空缓存并不合适。有两种主要策略。
策略一:版本命名空间 + TTL
数据管线每执行一次成功更新,datasetVersion 加一。查询请求总是从配置中心读取当前版本,然后访问对应命名空间的缓存。旧版本缓存保留在存储中,由 TTL 自然淘汰。
这个策略实现简单,版本切换后不会读到旧数据。代价是版本切换的瞬间缓存命中率会下跌,因为新版本命名空间下还没有缓存条目。如果数据集每小时更新一次,且查询分布相对集中,这种影响通常可以接受。
策略二:文档级失效
对于文档数量较少但更新频繁的场景,可以采用文档级失效。检索结果中保存每个切片的 docId 与 revision,在返回缓存之前,把切片版本与数据源当前版本做比对:
typescript
interface CachedRetrieval {
chunks: Array<{ docId: string; revision: string; content: string }>;
}
function isRetrievalCacheValid(
cached: CachedRetrieval,
currentRevisions: Map<string, string>
): boolean {
return cached.chunks.every((chunk) =>
currentRevisions.get(chunk.docId) === chunk.revision
);
}只要有一个切片的 revision 不匹配,说明数据集中对应文档发生了变化,缓存结果不再可靠。此时重新执行检索,并用新结果覆盖缓存条目。
文档级失效需要额外的存储来维护 docId -> revision 映射,每次请求多一次或多次读取。这是以读开销换缓存命中率与数据新鲜度的权衡。
7.3 语义缓存的失效
语义缓存无法通过精确缓存键直接失效。一种做法是:在语义缓存条目中保存“回答生成时使用到的切片列表”及其 docId 与 revision。命中语义缓存后,校验这些切片的 revision 是否仍然有效,无效则放弃缓存并重新生成。
如果语义缓存没有保存切片来源(例如缓存的是纯问答对),则只能依赖版本命名空间或 TTL 控制新鲜度。此时要接受一个窗口期:数据已更新,但语义缓存仍可能返回旧答案。
8 在线服务性能优化
性能优化从两个角度展开:单位请求的延迟,以及系统整体的吞吐。延迟主要由外部依赖决定,吞吐可以通过批量化和并发控制来提升。
8.1 端到端延迟预算
一次典型查询的延迟分解如下:
| 环节 | 耗时量级 |
|---|---|
| 解析与规范化 | 1 到 5 ms |
| Embedding 查询 | 20 到 80 ms |
| 向量检索 | 10 到 100 ms |
| 重排序 | 50 到 200 ms |
| LLM 首字返回 | 200 到 1000 ms 以上 |
延迟优化优先看占比最高的环节。多数情况下,LLM 生成是主要瓶颈,缓存和流式输出是最有效的削减手段。
8.2 连接池与批量请求
向量数据库客户端和 Embedding 服务客户端都应使用连接池,避免每次请求重建连接。Embedding 服务适合批量调用,一次传入多个文本比循环调用多次有更高的吞吐:
typescript
const embeddings = await embeddingClient.embedMany(
chunks.map((chunk) => chunk.content),
{ batchSize: 32 }
);批量大小需要根据服务的最大请求限制调整。超过限制会导致请求被拒绝,过小则无法充分利用吞吐。
8.3 流式输出
LLM 的首字延迟远大于后续 token 的平均延迟。将生成结果以 SSE 流式返回,可以让用户在生成第一个 token 时就开始阅读,显著降低感知延迟:
typescript
import express from 'express';
const app = express();
app.post('/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const query = req.body.query;
const messages = await buildMessages(query);
const stream = await llm.chat.completions.create({
model: 'llm-model',
messages,
stream: true
});
for await (const chunk of stream) {
const token: string | undefined =
chunk.choices[0]?.delta?.content;
if (token) {
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
}
res.write('data: [DONE]\n\n');
res.end();
});流式输出不减少总生成时间,但它把“等待最后一个 token”变成“读取第一个 token 后持续显示”,用户感知到的等待时间会明显缩短。
8.4 缓存预热
数据版本切换后,新版本缓存会经历冷启动。可以在切换完成前,使用最近一段时间内的热门查询预先执行一遍检索,写入新版本缓存。
注意点: 预热任务在数据管线中独立运行,不占用在线查询资源;用于预热的查询集必须来自真实请求分布。例如,可从访问日志中挑选最近一小时命中次数最高的前几百条查询,构造预热查询集。
预热完成后切换别名,在线服务的缓存命中率不会因版本切换而大幅下跌。
9 可观测性与基准测试
可观测性需要覆盖数据管线和在线查询两条链路。
9.1 数据链路指标
- 文档同步延迟:源数据变更到向量集合可见的时间
- 每分钟处理的文档数与切片数
- Embedding 任务成功率与重试率
- 索引重建任务状态(排队中、运行中、完成、失败)
这些指标用于判断数据更新是否滞后,以及索引版本切换是否完成。
9.2 查询链路指标
查询链路重点监控缓存命中率和延迟:
- 语义缓存命中率
- 检索结果缓存命中率
- Embedding 调用平均耗时与 P95
- 向量检索耗时
- 重排序耗时
- LLM 首字延迟与总生成时间
- 端到端请求耗时
缓存命中率按层级分别统计。当数据更新频繁但缓存命中率始终很高时,要检查数据版本是否在递增、索引是否真的在更新。
9.3 链路追踪
在查询入口生成 requestId,贯穿检索、缓存、生成三个环节,日志和指标都携带它:
typescript
const requestId = randomUUID();
logger.info({ requestId, query }, 'request start');链路追踪的关键是串联外部依赖的调用关系。使用 OpenTelemetry 时,可以为向量检索调用和 LLM 调用分别创建 Span,观察每个环节的耗时分布。
9.4 基准测试
性能基准测试需要一组与真实请求分布相近的查询样本,而不是两三个固定的测试问题。用这些样本分别测试缓存命中和缓存未命中两种情况,得到两组延迟曲线。缓存命中率对端到端延迟的影响,用这两组数据的对比来量化。
RAG 质量评测是另一条线,关注检索召回质量和答案正确性。工程化实践中,质量评测集与性能基准集通常是两套数据。
10 稳定性与降级机制
外部依赖故障是不可避免的。查询服务依赖向量数据库、Embedding 服务和 LLM,任何一个不可用,都应该有对应的降级路径。
| 故障点 | 降级行为 |
|---|---|
| 语义缓存不可用 | 跳过语义缓存,直接进入检索流程 |
| 向量数据库不可用 | 依赖检索结果缓存;没有缓存则返回服务不可用 |
| Embedding 服务不可用 | 降级为全文检索 |
| LLM 服务不可用 | 直接返回检索到的切片,并标明未生成回答 |
降级的关键是提前定义“最低可用状态”。如果 LLM 不可用,返回切片列表仍然对用户有参考价值;如果向量数据库也不可用,返回缓存列表已经是最后的可用选项。
限流和熔断通常应用于对外部依赖的调用。熔断器监测连续失败率,超过阈值后快速失败,不再继续向故障方发送请求。恢复后以少量流量探测,逐步放量。
Node.js 中一个简单的熔断逻辑:
typescript
class CircuitBreaker {
private failures = 0;
private state: 'closed' | 'open' | 'half-open' = 'closed';
private readonly threshold = 5;
private openedAt = 0;
private readonly cooldownMs = 30_000;
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.openedAt > this.cooldownMs) {
this.state = 'half-open';
} else {
throw new Error('circuit open');
}
}
try {
const result = await fn();
this.failures = 0;
this.state = 'closed';
return result;
} catch (err) {
this.failures += 1;
if (this.failures >= this.threshold) {
this.state = 'open';
this.openedAt = Date.now();
}
throw err;
}
}
}注意点: 这段代码只演示基本状态转移,没有处理并发请求下的状态竞争。实际实现建议使用 opossum 这类现成库。
11 RAG 工程化的模块划分
RAG 应用可以划分为五个模块:
- 数据接入模块:源数据变更捕获、文档解析、切片、Embedding
- 索引管理模块:向量集合的增删改查、全量重建、别名切换
- 缓存模块:多级缓存、缓存键设计、失效策略
- 检索与生成模块:过滤、混合检索、重排序、LLM 调用与流式输出
- 可观测性与稳定性模块:指标、追踪、日志、限流、熔断与降级
模块之间的接口如下:
- 数据接入模块输出“切片列表 + revision + 目标集合”
- 索引管理模块暴露“更新、删除、切换别名”操作
- 缓存模块依赖“版本号”做缓存键隔离
- 检索与生成模块消费“索引最新版本号 + 查询条件”
清晰的模块划分让各部分可以独立变化:接入新数据源不需要改动缓存模块,替换向量数据库不需要改动 LLM 调用部分,调整缓存失效策略也不会影响数据管线。
RAG 工程化的复杂度主要来自数据更新与缓存一致性的耦合。数据更新越频繁,缓存失效越复杂;缓存层次越深,数据变化对用户体验的影响越难追踪。把版本号作为数据更新与缓存之间的显式连接点,能让两组系统在各自演化的同时保持一致的视角。
