企业级 RAG 工程
从 Demo 到生产,差的不是一个向量数据库
一个最小 RAG Demo 通常只有四步:切块、向量化、检索、生成。它可以证明技术链路能工作, 却没有回答下面这些上线问题:
- 同一份文档被上传三次,会不会产生三套重复 chunk?
- 制度从 v3 更新到 v4 后,v3 什么时候不再参与检索?
- 源文档删除后,向量库、关键词索引和缓存里的副本是否都删除了?
- 用户从项目组退出后,昨天缓存的答案会不会继续展示?
- embedding 模型换代时,是否必须停服重建全库?
- CDC 消费者宕机重启,重复事件和乱序事件怎么处理?
- 一次错误回答,能否追到原文件版本、chunk、索引和模型调用?
- 向量库损坏后,恢复的是“有数据的库”,还是“与源数据一致的库”?
企业级 RAG 的本质是一个派生数据系统。原始文档、业务数据库和权限系统是事实源, chunk、embedding、倒排索引、摘要和答案缓存都是可重建的派生物。 这个判断会直接影响系统设计:不能只备份向量,不记录它从哪里、用什么规则生成。
先划清四类数据
系统混乱通常始于“所有东西都叫文档”。更稳妥的做法是区分四层:
| 层级 | 例子 | 是否事实源 | 主要责任 |
|---|---|---|---|
| Source | 对象存储里的 PDF、Wiki 页面、业务表 | 是 | 保存原始内容、业务版本和权限归属 |
| Canonical Document | 统一格式的正文、表格、结构树 | 否 | 屏蔽来源格式差异,保留解析质量与来源定位 |
| Retrieval Unit | chunk、表格行、代码符号、图片区域 | 否 | 成为可检索、可引用的最小证据单位 |
| Serving Artifact | 向量、倒排索引、缓存、摘要 | 否 | 为在线查询提供低延迟服务 |
删除 Source 时,后面三层都要随之失效;重建 Serving Artifact 时,不应反向修改事实源。 这就是生产系统必须保存血缘关系的原因。
文档接入状态机
“上传成功”不等于“可以被检索”。解析、OCR、分块、embedding、写索引任何一步都可能失败。 如果只有一个 processed=true,运维人员既不知道卡在哪,也不知道能否重试。
一套实用状态可以是:
RECEIVED
-> VALIDATING
-> PARSING
-> NORMALIZING
-> CHUNKING
-> EMBEDDING
-> INDEXING
-> VERIFYING
-> ACTIVE
任一步骤 -> RETRYABLE_FAILED
校验不通过 -> REJECTED
被新版本替换 -> SUPERSEDED
收到删除事件 -> DELETING -> DELETED
人工隔离 -> QUARANTINED状态转移要满足三个条件:
- 持久化:不能只存在内存任务队列里,进程重启后仍能恢复。
- 单调或受控回退:旧事件不能把
ACTIVE写回PARSING;人工重跑要生成新的 attempt。 - 带原因:保存错误码、依赖、重试次数、下一次重试时间,而不是只有一段异常字符串。
一个接入作业记录可以这样设计:
{
"job_id": "ingest_01J...",
"tenant_id": "company-a",
"document_id": "policy-travel",
"source_version": "42",
"pipeline_version": "rag-ingest-2026-09",
"state": "EMBEDDING",
"attempt": 2,
"content_hash": "sha256:7c9...",
"last_error": {
"code": "EMBEDDING_RATE_LIMIT",
"retryable": true
},
"updated_at": "2026-09-07T08:30:00Z"
}ACTIVE 最好在索引验证后再设置。验证至少包括:预期 chunk 数与实际写入数一致、随机抽样能按 id 读回、 向量维度正确、权限字段存在、源版本仍是当前版本。否则可能出现“管理后台显示成功,查询却只有半份文档”。
幂等:允许消息重复,但结果不能重复
消息队列、CDC 和网络重试通常提供的是至少一次投递。同一事件被处理两次不是异常,而是必须接受的工作条件。 PostgreSQL 的逻辑解码文档也明确指出, 崩溃后逻辑复制槽可能再次发送近期变更,客户端需要处理重复消息。
幂等键不能随手用“当前时间 + 文件名”。它应稳定描述同一项工作:
idempotency_key = tenant_id
+ source_system
+ source_object_id
+ source_version
+ pipeline_version其中:
source_version区分业务内容的不同版本;pipeline_version区分分块、解析或 embedding 规则的变化;tenant_id防止不同租户恰好使用相同文档 id;- 数据库唯一约束负责最终兜底,不能只靠“先查有没有”。
CREATE TABLE ingestion_job (
idempotency_key text PRIMARY KEY,
tenant_id text NOT NULL,
document_id text NOT NULL,
source_version text NOT NULL,
pipeline_version text NOT NULL,
state text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);SELECT 后再 INSERT 存在并发竞态。应使用唯一键配合 INSERT ... ON CONFLICT,并让每个阶段也可重复执行:
- chunk id 是确定性的,重复分块得到相同 id;
- embedding 写入用 upsert;
- 删除用
DELETE WHERE document_version_id = ?,重复删除仍成功; - 状态更新带 expected state 或 revision,拒绝旧 worker 覆盖新结果。
内容哈希:判断“内容真的变了吗”
来源系统的 updated_at 不可靠:移动目录、改权限、修改标签都可能更新时间,但正文没变; 有些旧系统反过来会改内容却不更新版本号。内容哈希提供第二层判断。
import hashlib
import json
def canonical_hash(text: str, structural_meta: dict) -> str:
payload = {
"text": text.replace("\r\n", "\n").strip(),
"meta": structural_meta,
}
raw = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(raw).hexdigest()哈希前要先定义 canonicalization,否则换行、字段顺序或无意义空格都会制造假变更。 同时不能把所有 metadata 都放进去:last_viewed_at 变化不应触发重新 embedding, 但标题、章节路径、有效期可能改变检索含义,应进入哈希或单独触发索引更新。
常见的三级哈希:
| 哈希 | 输入 | 用途 |
|---|---|---|
binary_hash | 原始文件字节 | 判断上传对象是否完全相同 |
content_hash | 标准化正文与结构 | 判断是否需要重解析、重分块 |
chunk_hash | chunk 正文 + 影响语义的上下文 | 复用 embedding,避免未变化 chunk 重算 |
哈希不能替代版本号。哈希回答“内容是否相同”,版本号回答“业务上这是哪次发布”。 两份字节相同的制度可能属于不同审批批次,审计时仍需分别记录。
增量索引与 CDC
增量索引有三种常见触发方式:
| 方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 定时扫描 | 实现简单、容易补偿 | 新鲜度取决于周期,扫描成本高 | 文件目录、小规模知识库 |
| Webhook / 业务事件 | 延迟低、语义清楚 | 来源要可靠发事件,可能漏发 | Wiki、内容平台、SaaS API |
| CDC | 从数据库日志捕获 insert/update/delete | 运维复杂,需要处理顺序、重复和 schema 变化 | 业务数据库、规模化增量同步 |
CDC 不是“监听一张表然后立即写向量库”这么简单。稳妥链路通常是:
事件里只放完整内容还是只放 id,需要权衡:
- Fat event 带内容,重放时不依赖源库当前状态,但事件大、敏感数据扩散范围更广;
- Thin event 只带 id/version,消费者回源读取,事件小,但回源时记录可能已经再次变化或被删除;
- 常见折中是事件带
before/after的关键字段、版本和日志位置,正文从不可变快照读取。
CDC 消费者至少要处理:
- 重复:同一 LSN、offset 或 event id 再次到达;
- 乱序:版本 43 先完成,版本 42 后完成;
- 大事务:一次发布修改数万条,不能把在线索引压垮;
- schema 演进:新增字段、字段改名、枚举变化;
- 毒消息:单条解析失败不能阻塞整个分区;
- 复制槽积压:消费者停滞会让数据库保留 WAL,占满磁盘。
最重要的防乱序规则是 compare-and-set:只有事件版本大于当前激活版本时,才允许切换 current pointer。
初始快照与增量流之间不能有缝
新建知识库时,先全量导入、再开启 CDC,如果两个动作没有一致性位置,就会漏掉导入期间发生的更新。 正确抽象是:
- 记录变更流起点
P0并取得对应的一致性快照; - 从快照导入全量数据;
- 消费
P0之后的增量事件; - 追平后切换为实时消费。
不同数据库实现不同,但目标相同:全量快照和增量日志必须在同一个时间边界上衔接。 恢复和迁移时也要记录 checkpoint,而不是凭“昨天大概跑完了”判断起点。
更新传播:先建新版本,再原子切换
直接覆盖旧 chunk 有两个风险:用户可能在重建中途读到半新半旧的数据;失败后也没有可回滚版本。 更稳的方式是 copy-on-write:
v3 ACTIVE
|
收到 v4
-> 构建 v4 canonical document
-> 生成 v4 chunks / embeddings / keyword entries
-> 校验 v4 完整性
-> 原子更新 current_version: v3 -> v4
-> v3 标记 SUPERSEDED,延迟回收在线检索固定读取一次 knowledge_snapshot_id 或版本水位,避免一次请求先查到 v3 的正文、 后查到 v4 的附件。对于强一致制度查询,可以要求所有证据来自同一发布快照; 新闻或网页搜索则可以接受最终一致,并在答案上展示抓取时间。
只更新变化的 chunk
如果 200 页手册只改了一段,不一定要重算全部 embedding:
- 用稳定结构定位章节,而不是只按数组序号匹配;
- 比较新旧
chunk_hash; - 未变化 chunk 复用向量;
- 新增和修改 chunk 重新计算;
- 消失的 chunk 写 tombstone 并从 serving index 删除。
但分块器版本变化时,chunk 边界可能整体变化,此时“文本看起来差不多”并不足以复用。 缓存键中必须包含 chunker_version 和影响 embedding 的上下文模板版本。
删除传播:删除不是把后台按钮变灰
删除可能来自用户主动删除、源系统撤稿、保留期到期、权限收回或合规请求。 删除链路至少涉及:
Source tombstone
-> canonical document
-> chunks
-> dense index
-> sparse index
-> reranker feature store
-> summaries / graph edges
-> retrieval cache
-> answer cache
-> backups according to retention policy推荐先写 tombstone,再异步物理删除:
- tombstone 立即阻止在线检索返回该版本;
- 各派生存储按 job 重试物理清理;
- 删除完成表记录每个 sink 的状态;
- 合规删除需要可审计证明,但审计记录不能继续保存被要求删除的正文。
“软删除后仍能被向量索引召回,再由应用过滤”不够稳。过滤漏掉一次就会泄露, 而且缓存、离线导出和运维检索也可能绕过应用层。
Document、Chunk、Version Lineage
血缘要能从答案反查到源,也要能从源正向找到所有派生物。推荐至少有这些 id:
source_object_id
-> document_id # 跨版本稳定的逻辑文档
-> document_version_id # 一次业务发布
-> canonical_artifact_id # 某解析器产物
-> chunk_id # 某分块版本的检索单元
-> embedding_id # 某模型与维度生成的向量
-> sparse_entry_idchunk id 不要只用 document_id:index。在前面插入一段后,所有序号都会变化。 可以使用结构路径、局部内容哈希和分块器版本组成稳定 id:
chunk_id = hash(
tenant_id,
document_version_id,
section_path,
local_content_hash,
chunker_version
)一次回答还要保存 serving lineage:
{
"trace_id": "trace_01J...",
"knowledge_snapshot_id": "kb_2026_09_07_0830",
"query_hash": "sha256:...",
"retrieved_chunk_ids": ["chunk_a", "chunk_b"],
"reranker_version": "rerank-v4",
"prompt_version": "answer-grounded-v7",
"model": "provider/model-version",
"citations": [{"claim": 0, "chunk_id": "chunk_a"}]
}没有这些字段,线上回归只能看到“今天答案变了”,却无法判断是文档更新、分块变化、模型变化还是缓存命中。
新旧版本与时间有效性
updated_at 不是完整的时间模型。企业制度常见四种时间:
published_at:何时发布;valid_from:业务上何时生效;valid_to:何时失效;ingested_at:系统何时接入。
用户问“2025 年 12 月发生的报销按什么规则”时,应该按业务有效期检索,而不是只返回当前最新文档。 这类查询可使用双时间模型:
valid time:事实在业务世界何时成立
system time:系统从何时开始知道这条事实检索过滤可以表达为:
WHERE tenant_id = :tenant_id
AND valid_from <= :as_of
AND (valid_to IS NULL OR :as_of < valid_to)
AND system_from <= :known_at
AND (system_to IS NULL OR :known_at < system_to)如果用户没给时间,默认策略要显式定义,例如取当前有效版本;不能让向量相似度在新旧制度之间自由排序。 证据冲突时应展示版本和有效期,不能让 LLM 自己猜哪份“更权威”。
ACL 必须前置过滤
权限安全的底线是:无权证据不能进入候选集、reranker、prompt、缓存或普通日志。
错误:全库召回 -> LLM/reranker 看见 -> 最后隐藏来源
正确:身份解析 -> 服务端生成安全过滤 -> 受限召回 -> 后续处理ACL 常见模型:
| 模型 | 表达 | 适合场景 |
|---|---|---|
| RBAC | 角色可访问某集合 | 权限规则相对稳定 |
| ABAC | 部门、地域、级别、项目等属性组合 | 企业复杂策略 |
| ReBAC | 用户与文档通过项目、组织、共享关系相连 | 协作平台、图关系权限 |
不要让模型或客户端传入可信的 tenant_id、allowed_groups。这些值必须从已认证身份和权限服务计算:
def secure_retrieve(user_context, query, requested_filters):
policy = authorization_service.resolve(user_context)
safe_filter = {
"tenant_id": policy.tenant_id,
"acl_version": policy.version,
"document_ids": policy.allowed_document_ids,
}
business_filter = validate_business_filters(requested_filters)
return index.search(query, filter=and_(safe_filter, business_filter))若允许文档级 ACL,但 chunk 没继承 ACL,检索层仍无法安全过滤。权限变更时,要么更新所有 chunk 的可过滤字段, 要么查询时与权限索引做高效交集。无论哪种方案,都要测试“撤权后多久不可见”。
权限过滤为什么可能损伤召回
某些 ANN 索引先找近邻再过滤。如果当前用户只能看到很少的数据,top 100 候选都被过滤掉后可能返回空结果, 即使全库中存在可见的相关文档。要确认向量库支持的 pre-filter 行为,并用低选择率 ACL 场景压测。 必要时按租户/安全域分区、增加搜索深度,或使用支持过滤感知的索引策略。
Tenant Isolation 不只是一列 tenant_id
多租户隔离至少有三层:
- 逻辑隔离:每条记录强制带 tenant filter;成本低,但一次漏条件就可能越权。
- 集合或命名空间隔离:每租户独立 collection/index;边界更清楚,租户多时管理成本上升。
- 实例/账号隔离:高敏租户独立基础设施与密钥;成本最高,故障域和合规边界最好。
选型看数据敏感度、租户数量、单租户规模、密钥要求和资源噪声,而不是只看开发便利。 无论使用哪层,都应做到:
- 服务端从认证上下文注入租户,不信任请求体;
- 缓存键包含租户与权限版本;
- 日志、离线评测、导出和运维后台同样隔离;
- 限流和容量配额按租户统计,防止 noisy neighbor;
- 加密密钥与备份恢复遵循相同边界。
四类缓存不能混为一谈
RAG 系统至少会出现四类缓存,它们的键、收益和失效方式不同。
1. Embedding Cache
缓存文档或 query 的向量,避免相同文本反复调用 embedding 服务。
key = hash(
canonical_text,
embedding_model,
model_revision,
dimensions,
normalization,
task_type
)文档 embedding 缓存命中率通常比 query 更容易提升,因为重试、重建和跨版本未改 chunk 会重复出现。 缓存内容不必因文档 ACL 变化而重算,但取用时仍要与文档血缘关联;敏感文本的哈希也可能遭字典攻击, 不能把裸哈希当匿名化。
2. Query Cache
缓存标准化 query、改写结果、query embedding 或路由决策。键中至少包含租户、语言、改写器版本和会话依赖摘要。 多轮对话中的“这个政策呢”不能只按原句缓存,因为不同会话里的“这个”指向不同对象。
3. Retrieval Cache
缓存某个查询对应的 chunk id 和分数:
key = tenant
+ auth_scope_hash
+ normalized_query
+ filters
+ index_snapshot
+ retriever_version
+ top_k把 index_snapshot 放进键中是一种简单可靠的失效方式:新知识快照上线后,自然读不到旧缓存。 代价是每次发布都会降低命中率。规模更大时可以维护 document-to-cache-key 反向依赖,做精确失效, 但反向索引本身也需要可靠更新。
4. Answer Cache
缓存最终答案最危险,因为答案还依赖 prompt、生成模型、证据、用户语言、权限和时间。 适合缓存的是高频、稳定、非个性化 FAQ;不适合余额、订单状态、刚发布政策或用户特定判断。
answer_key = tenant
+ auth_scope_hash
+ normalized_question
+ evidence_fingerprint
+ prompt_version
+ generation_model
+ safety_policy_version命中 answer cache 前仍要确认权限没有收回、证据版本仍有效。对于高风险回答,宁可只缓存检索结果,重新生成并校验引用。
缓存失效矩阵
| 变化 | Embedding | Query | Retrieval | Answer |
|---|---|---|---|---|
| 文档正文更新 | 变化 chunk 失效 | 通常不失效 | 相关快照失效 | 相关答案失效 |
| ACL 收回 | 不必重算向量 | 权限相关项失效 | 必须失效 | 必须失效 |
| embedding 模型变化 | 必须分版本 | query vector 失效 | 必须分索引 | 证据变化时失效 |
| reranker 变化 | 不影响 | 不影响 | 必须分版本 | 建议失效 |
| prompt/生成模型变化 | 不影响 | 不影响 | 不影响 | 必须分版本 |
到达 valid_to | 不影响向量 | 不影响 | 必须失效 | 必须失效 |
TTL 只能限制陈旧时间,不能保证权限撤销立即生效。安全相关变更应使用主动失效或在线授权复核。
Embedding 模型升级
新旧 embedding 模型的向量不在同一个语义空间,不能把新 query 向量直接搜索旧文档向量。 可靠升级需要把模型、维度、归一化、task prefix 和分块规则视为一个不可分割的索引版本。
双写与双读
- 双写:迁移期间新增和更新文档同时进入 v1、v2,防止回填永远追不上变化。
- 历史回填:按租户或文档分片扫描,用 checkpoint 断点续跑,限制 embedding QPS 和索引写入速率。
- 双读/影子读:线上请求主结果来自 v1,同时异步查询 v2,只记录差异,不影响用户。
- 灰度:按稳定 hash 将少量租户或请求路由到 v2,避免同一用户结果来回跳。
校验不能只比条数
上线门槛至少包括:
- 预期文档、版本、chunk 数和索引记录数一致;
- 维度、距离度量、归一化配置正确;
- ACL 与有效期字段覆盖率为 100%,否则禁止激活;
- 固定评测集上的 Recall@k、MRR、无答案误答率不低于门槛;
- P50/P95/P99 延迟和单位查询成本在预算内;
- 关键租户、长尾语言、编号查询没有明显退化;
- 删除事件在 v1、v2 都能传播。
回滚不是把配置改回旧模型这么简单。旧索引必须在观察期继续接收更新,或能够从事件日志补齐灰度期间的变更, 否则回滚后看到的是旧数据。
可观测性:一次回答要有一条完整 Trace
OpenTelemetry 将 trace 描述为一次请求穿过应用的完整路径, span 表示其中一个工作单元。RAG 特别适合用 trace,因为“答案慢或错”往往跨越多个服务。
rag.request
|- auth.resolve
|- query.rewrite
|- embedding.query
|- retrieve.dense
|- retrieve.sparse
|- fuse.rrf
|- rerank
|- context.build
|- llm.generate
`- citation.verify每个 span 可记录:
- 版本:retriever、index snapshot、embedding、reranker、prompt、LLM;
- 数量:候选数、过滤后数量、最终证据数、输入输出 token;
- 时间:排队、网络、模型推理、数据库查询、总延迟;
- 质量信号:最高分、分数间隔、引用覆盖、拒答原因;
- 成本:各模型调用和存储/检索的估算成本;
- 状态:命中哪层缓存、是否降级、哪个依赖超时。
不要默认把用户原问题、完整 chunk、prompt 和答案写进普通 trace。它们可能包含隐私和商业数据。 可记录哈希、长度、类别和受控采样,原文只进入有访问控制、保留期明确的安全审计存储。
指标、SLO 与告警
SLO 不能只有 API 可用率。一个返回 HTTP 200 但使用过期资料的 RAG,并不算真正可用。
| SLI | 示例定义 | 说明 |
|---|---|---|
| Query availability | 成功完成或明确拒答的请求比例 | 依赖超时不能伪装成“无资料” |
| End-to-end latency | 从收到请求到流式首 token / 完成 | 首 token 和总时长分别统计 |
| Freshness lag | 源版本生效到可检索的时长 | 按数据源和优先级分桶 |
| Deletion lag | 删除/撤权到所有 serving path 不可见的时长 | 安全 SLO,要求通常更严 |
| Retrieval quality | 固定集 Recall@k、MRR 或线上弱标签 | 不能只用相似度分数 |
| Grounded answer rate | 关键结论有支持证据的比例 | 需配合引用验证 |
| Cache effectiveness | 命中率、节省成本、陈旧命中数 | 命中率高不代表安全 |
例如,可以把“高优先级制度更新 10 分钟内可检索”设为内部目标,并对一个统计窗口计算:
good_events = count(freshness_lag <= 10 min)
freshness_SLI = good_events / all_high_priority_change_events具体目标值必须根据业务风险和预算制定,不能从教程抄一个百分比。错误预算则用来决定: 还能继续发布索引优化,还是先处理新鲜度、权限或稳定性欠账。
容量估算
向量存储
原始向量大小:
vector_bytes = chunk_count * dimensions * bytes_per_dimension假设 500 万 chunk、1024 维、float32:
5,000,000 * 1,024 * 4 bytes
= 20,480,000,000 bytes
≈ 20.48 GB(十进制)这只是原始向量。实际容量还要加 ANN 索引、文本、metadata、WAL、临时构建空间、副本和备份。 不要凭一个固定“乘 1.5”估算所有数据库,应在目标引擎上用代表性数据实测索引放大率。
入库吞吐
daily_embedding_tokens
= changed_documents_per_day
* avg_chunks_per_document
* avg_tokens_per_chunk
* (1 - reusable_chunk_ratio)再用 provider 的批量限制、QPS、token/min 限额和重试余量计算完成窗口。 回填期间还要给在线 query embedding 留出配额,不能让离线任务挤占线上服务。
在线并发
可用 Little's Law 做第一轮估算:
in_flight ≈ requests_per_second * average_latency_seconds如果 40 RPS、平均端到端 2.5 秒,系统平均约有 100 个请求在途。 P95 尾延迟、突发流量和各阶段连接池仍需压测,不应只按平均值配置。
成本估算
一次查询的可变成本可以拆成:
C_query
= C_query_embedding
+ C_rewrite
+ C_vector_search
+ C_keyword_search
+ C_rerank
+ C_generation_input
+ C_generation_output
+ C_observability
- C_cache_saved其中模型 token 成本按供应商当前计价计算。若 API 返回的 input_tokens 已包含缓存命中的输入, 先算出未缓存部分,避免重复计费:
uncached_input_tokens = input_tokens - cached_input_tokens
C_model = uncached_input_tokens / 1,000,000 * input_price
+ cached_input_tokens / 1,000,000 * cached_input_price
+ output_tokens / 1,000,000 * output_price如果某家供应商把缓存 token 单列且不计入 input_tokens,则按它的 usage 定义调整,不能照搬上式。 价格、缓存计费、批处理折扣和最小计费单位会变化,必须从实际账单或官方价格页读取,不把教程中的数字写死。 存储成本也不能只算向量 GB:还包括副本、备份、出网、索引构建资源和峰值预留。
建议按 tenant_id / use_case / model_version 聚合成本,并记录“每个成功且有依据回答”的成本。 如果只看每次 API 调用便宜,可能忽略低质量检索导致的重复查询和人工客服成本。
备份、恢复与重建
首先确定 RPO 和 RTO:
- RPO:最多允许丢失多少时间范围的数据;
- RTO:故障后多久恢复服务。
事实源、metadata 数据库、事件位置和 serving index 的备份策略不同:
| 对象 | 是否必须备份 | 恢复重点 |
|---|---|---|
| 原始文档/业务库 | 必须 | 版本、ACL、保留策略 |
| 血缘与接入状态 | 必须 | 能知道哪些派生物完整、哪些需重建 |
| CDC offset/checkpoint | 必须 | 避免漏事件;重复可由幂等处理 |
| 向量/关键词索引 | 视重建时间决定 | 快照需与 metadata/版本水位一致 |
| 缓存 | 通常不必 | 清空后应只影响性能,不影响正确性 |
“每天备份成功”不等于可恢复。恢复演练应验证:
- 在隔离环境恢复事实源和 metadata;
- 恢复或重建索引;
- 从正确 checkpoint 重放增量;
- 比较文档数、chunk 数、删除 tombstone 和 ACL 覆盖;
- 运行黄金评测集与越权测试;
- 达到一致水位后再接流量。
索引快照和数据库快照若不在同一水位,恢复后可能引用不存在的 chunk,或漏掉已提交版本。
数据合规
RAG 会复制数据:原文进入解析服务、embedding 提供商、向量库、日志、评测集、备份和缓存。 合规盘点要沿数据流做,而不是只问“向量库是否加密”。
- 数据分类:公开、内部、机密、个人信息分别允许进入哪些处理器;
- 数据最小化:embedding 前删除不需要检索的身份证号、密钥和隐私字段;
- 传输与静态加密:密钥轮换、租户密钥边界和访问审计;
- 地域与供应商:数据是否允许离开指定区域,第三方是否用于训练;
- 保留期:原文、日志、trace、评测样本和备份分别保留多久;
- 删除权:能够定位并删除某主体数据的所有派生物;
- 运维访问:break-glass 权限应限时、审批并审计。
Embedding 不是匿名化。向量可能泄露成员关系或语义信息,仍应按来源数据等级保护。
生产故障怎么排
故障一:更新已发布,但答案仍旧
按链路查:源版本是否变化 -> 事件是否发出 -> CDC 是否积压 -> 接入 job 是否 ACTIVE -> current pointer 是否切换 -> 检索是否命中新版本 -> retrieval/answer cache 是否失效。 不要先改 prompt,旧证据进入上下文时,生成层很难修正。
故障二:某租户偶发搜到另一租户标题
立即按安全事件处理:停用可疑缓存或检索路径,保存审计证据,检查 tenant filter 是否服务端注入、 缓存键是否含 tenant、异步 reranker 是否收到过滤前候选、运维接口是否绕过策略。 修复后必须加入负向越权回归,不以“暂时没复现”结案。
故障三:CDC 延迟突然增长
检查消费者错误率和吞吐、队列分区热点、embedding 限流、向量库写入延迟、毒消息、复制槽/WAL 占用。 可以按业务优先级降级:高优先级制度先处理,低优先级历史归档延后;但不能跳过事件且不留补偿记录。
故障四:向量库正常,召回突然下降
核对 query 与文档的 embedding 模型、维度、归一化和 task prefix;检查索引参数、过滤选择率、 新索引是否回填完整、分块版本是否变化。服务健康检查只能证明端口可用,不能证明语义检索正确。
故障五:成本突然翻倍
按 trace 拆分:缓存命中是否下降、chunk 是否变长、top_k 是否增大、reranker 候选是否膨胀、 Agent 是否重复检索、输出 token 是否失控、回填任务是否与线上共用计费配额。 最终要定位到版本和调用阶段,而不是笼统归因于“用户变多”。
一套可落地的生产检查顺序
- 明确事实源、数据所有者、版本字段和删除语义。
- 建立接入状态机、幂等键、内容哈希和可恢复 checkpoint。
- 设计 document/version/chunk/embedding 血缘与 current pointer。
- 先实现更新、删除、撤权传播,再扩大数据量。
- 把 tenant 与 ACL 过滤固定在服务端检索入口。
- 分开设计四类缓存及主动失效事件。
- 给一次查询建立跨服务 trace,并保护敏感内容。
- 定义新鲜度、删除延迟、可用性、质量和成本 SLO。
- 用代表性数据测容量、回填速度和 ACL 低选择率性能。
- 按双索引流程演练一次 embedding 升级与回滚。
- 做备份恢复、CDC 断点、索引损坏和权限泄露演练。
- 把评测、变更记录和审计证据纳入发布门禁。
关键理解
企业级 RAG 的可靠性不来自某个“企业版向量库”,而来自几个可以验证的约束:
- 每次变更可重复处理,且不会产生重复最终状态;
- 每条证据有版本、有效期、权限和完整血缘;
- 新版本完整后再切换,旧版本能回滚且最终会回收;
- 删除和撤权比普通更新更快、更严格地传播;
- 缓存命中不能绕过新鲜度和授权;
- 每次回答都能通过 trace 解释用了什么数据和模型;
- 索引是派生物,系统始终具备从事实源重建的能力。
面试中如果被问“怎么把 RAG 上生产”,不要只回答部署向量数据库和加 Redis。 先说事实源、增量变更、版本切换和权限边界,再谈缓存、观测、成本和灾备,答案才形成完整闭环。
先看一个小场景
制度从 v3 更新到 v4 后,员工马上问“报销上限是多少”。系统既要让 v4 尽快可检索,又要在重建失败时保留可回滚的 v3;员工离职后,旧缓存还不能继续展示。版本、ACL、索引任务和缓存失效必须放在同一条可追踪的更新链路里。
读完后可以模拟一次制度版本切换和员工离职,检查索引、缓存和 ACL 是否沿同一条链路更新。