Skip to content

企业级 RAG 工程 ​

从 Demo 到生产,差的不是一个向量数据库 ​

一个最小 RAG Demo 通常只有四步:切块、向量化、检索、生成。它可以证明技术链路能工作, 却没有回答下面这些上线问题:

  • 同一份文档被上传三次,会不会产生三套重复 chunk?
  • 制度从 v3 更新到 v4 后,v3 什么时候不再参与检索?
  • 源文档删除后,向量库、关键词索引和缓存里的副本是否都删除了?
  • 用户从项目组退出后,昨天缓存的答案会不会继续展示?
  • embedding 模型换代时,是否必须停服重建全库?
  • CDC 消费者宕机重启,重复事件和乱序事件怎么处理?
  • 一次错误回答,能否追到原文件版本、chunk、索引和模型调用?
  • 向量库损坏后,恢复的是“有数据的库”,还是“与源数据一致的库”?

企业级 RAG 的本质是一个派生数据系统。原始文档、业务数据库和权限系统是事实源, chunk、embedding、倒排索引、摘要和答案缓存都是可重建的派生物。 这个判断会直接影响系统设计:不能只备份向量,不记录它从哪里、用什么规则生成。

先划清四类数据 ​

系统混乱通常始于“所有东西都叫文档”。更稳妥的做法是区分四层:

层级例子是否事实源主要责任
Source对象存储里的 PDF、Wiki 页面、业务表是保存原始内容、业务版本和权限归属
Canonical Document统一格式的正文、表格、结构树否屏蔽来源格式差异,保留解析质量与来源定位
Retrieval Unitchunk、表格行、代码符号、图片区域否成为可检索、可引用的最小证据单位
Serving Artifact向量、倒排索引、缓存、摘要否为在线查询提供低延迟服务

删除 Source 时,后面三层都要随之失效;重建 Serving Artifact 时,不应反向修改事实源。 这就是生产系统必须保存血缘关系的原因。

文档接入状态机 ​

“上传成功”不等于“可以被检索”。解析、OCR、分块、embedding、写索引任何一步都可能失败。 如果只有一个 processed=true,运维人员既不知道卡在哪,也不知道能否重试。

一套实用状态可以是:

text
RECEIVED
  -> VALIDATING
  -> PARSING
  -> NORMALIZING
  -> CHUNKING
  -> EMBEDDING
  -> INDEXING
  -> VERIFYING
  -> ACTIVE

任一步骤 -> RETRYABLE_FAILED
校验不通过 -> REJECTED
被新版本替换 -> SUPERSEDED
收到删除事件 -> DELETING -> DELETED
人工隔离 -> QUARANTINED

状态转移要满足三个条件:

  1. 持久化:不能只存在内存任务队列里,进程重启后仍能恢复。
  2. 单调或受控回退:旧事件不能把 ACTIVE 写回 PARSING;人工重跑要生成新的 attempt。
  3. 带原因:保存错误码、依赖、重试次数、下一次重试时间,而不是只有一段异常字符串。

一个接入作业记录可以这样设计:

json
{
  "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 的逻辑解码文档也明确指出, 崩溃后逻辑复制槽可能再次发送近期变更,客户端需要处理重复消息。

幂等键不能随手用“当前时间 + 文件名”。它应稳定描述同一项工作:

text
idempotency_key = tenant_id
                + source_system
                + source_object_id
                + source_version
                + pipeline_version

其中:

  • source_version 区分业务内容的不同版本;
  • pipeline_version 区分分块、解析或 embedding 规则的变化;
  • tenant_id 防止不同租户恰好使用相同文档 id;
  • 数据库唯一约束负责最终兜底,不能只靠“先查有没有”。
sql
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 不可靠:移动目录、改权限、修改标签都可能更新时间,但正文没变; 有些旧系统反过来会改内容却不更新版本号。内容哈希提供第二层判断。

python
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_hashchunk 正文 + 影响语义的上下文复用 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,如果两个动作没有一致性位置,就会漏掉导入期间发生的更新。 正确抽象是:

  1. 记录变更流起点 P0 并取得对应的一致性快照;
  2. 从快照导入全量数据;
  3. 消费 P0 之后的增量事件;
  4. 追平后切换为实时消费。

不同数据库实现不同,但目标相同:全量快照和增量日志必须在同一个时间边界上衔接。 恢复和迁移时也要记录 checkpoint,而不是凭“昨天大概跑完了”判断起点。

更新传播:先建新版本,再原子切换 ​

直接覆盖旧 chunk 有两个风险:用户可能在重建中途读到半新半旧的数据;失败后也没有可回滚版本。 更稳的方式是 copy-on-write:

text
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:

  1. 用稳定结构定位章节,而不是只按数组序号匹配;
  2. 比较新旧 chunk_hash;
  3. 未变化 chunk 复用向量;
  4. 新增和修改 chunk 重新计算;
  5. 消失的 chunk 写 tombstone 并从 serving index 删除。

但分块器版本变化时,chunk 边界可能整体变化,此时“文本看起来差不多”并不足以复用。 缓存键中必须包含 chunker_version 和影响 embedding 的上下文模板版本。

删除传播:删除不是把后台按钮变灰 ​

删除可能来自用户主动删除、源系统撤稿、保留期到期、权限收回或合规请求。 删除链路至少涉及:

text
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:

text
source_object_id
  -> document_id                  # 跨版本稳定的逻辑文档
    -> document_version_id        # 一次业务发布
      -> canonical_artifact_id    # 某解析器产物
        -> chunk_id               # 某分块版本的检索单元
          -> embedding_id         # 某模型与维度生成的向量
          -> sparse_entry_id

chunk id 不要只用 document_id:index。在前面插入一段后,所有序号都会变化。 可以使用结构路径、局部内容哈希和分块器版本组成稳定 id:

text
chunk_id = hash(
  tenant_id,
  document_version_id,
  section_path,
  local_content_hash,
  chunker_version
)

一次回答还要保存 serving lineage:

json
{
  "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 月发生的报销按什么规则”时,应该按业务有效期检索,而不是只返回当前最新文档。 这类查询可使用双时间模型:

text
valid time:事实在业务世界何时成立
system time:系统从何时开始知道这条事实

检索过滤可以表达为:

sql
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、缓存或普通日志。

text
错误:全库召回 -> LLM/reranker 看见 -> 最后隐藏来源
正确:身份解析 -> 服务端生成安全过滤 -> 受限召回 -> 后续处理

ACL 常见模型:

模型表达适合场景
RBAC角色可访问某集合权限规则相对稳定
ABAC部门、地域、级别、项目等属性组合企业复杂策略
ReBAC用户与文档通过项目、组织、共享关系相连协作平台、图关系权限

不要让模型或客户端传入可信的 tenant_id、allowed_groups。这些值必须从已认证身份和权限服务计算:

python
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 ​

多租户隔离至少有三层:

  1. 逻辑隔离:每条记录强制带 tenant filter;成本低,但一次漏条件就可能越权。
  2. 集合或命名空间隔离:每租户独立 collection/index;边界更清楚,租户多时管理成本上升。
  3. 实例/账号隔离:高敏租户独立基础设施与密钥;成本最高,故障域和合规边界最好。

选型看数据敏感度、租户数量、单租户规模、密钥要求和资源噪声,而不是只看开发便利。 无论使用哪层,都应做到:

  • 服务端从认证上下文注入租户,不信任请求体;
  • 缓存键包含租户与权限版本;
  • 日志、离线评测、导出和运维后台同样隔离;
  • 限流和容量配额按租户统计,防止 noisy neighbor;
  • 加密密钥与备份恢复遵循相同边界。

四类缓存不能混为一谈 ​

RAG 系统至少会出现四类缓存,它们的键、收益和失效方式不同。

1. Embedding Cache ​

缓存文档或 query 的向量,避免相同文本反复调用 embedding 服务。

text
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 和分数:

text
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;不适合余额、订单状态、刚发布政策或用户特定判断。

text
answer_key = tenant
           + auth_scope_hash
           + normalized_question
           + evidence_fingerprint
           + prompt_version
           + generation_model
           + safety_policy_version

命中 answer cache 前仍要确认权限没有收回、证据版本仍有效。对于高风险回答,宁可只缓存检索结果,重新生成并校验引用。

缓存失效矩阵 ​

变化EmbeddingQueryRetrievalAnswer
文档正文更新变化 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,因为“答案慢或错”往往跨越多个服务。

text
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 分钟内可检索”设为内部目标,并对一个统计窗口计算:

text
good_events = count(freshness_lag <= 10 min)
freshness_SLI = good_events / all_high_priority_change_events

具体目标值必须根据业务风险和预算制定,不能从教程抄一个百分比。错误预算则用来决定: 还能继续发布索引优化,还是先处理新鲜度、权限或稳定性欠账。

容量估算 ​

向量存储 ​

原始向量大小:

text
vector_bytes = chunk_count * dimensions * bytes_per_dimension

假设 500 万 chunk、1024 维、float32:

text
5,000,000 * 1,024 * 4 bytes
= 20,480,000,000 bytes
≈ 20.48 GB(十进制)

这只是原始向量。实际容量还要加 ANN 索引、文本、metadata、WAL、临时构建空间、副本和备份。 不要凭一个固定“乘 1.5”估算所有数据库,应在目标引擎上用代表性数据实测索引放大率。

入库吞吐 ​

text
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 做第一轮估算:

text
in_flight ≈ requests_per_second * average_latency_seconds

如果 40 RPS、平均端到端 2.5 秒,系统平均约有 100 个请求在途。 P95 尾延迟、突发流量和各阶段连接池仍需压测,不应只按平均值配置。

成本估算 ​

一次查询的可变成本可以拆成:

text
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 已包含缓存命中的输入, 先算出未缓存部分,避免重复计费:

text
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/版本水位一致
缓存通常不必清空后应只影响性能,不影响正确性

“每天备份成功”不等于可恢复。恢复演练应验证:

  1. 在隔离环境恢复事实源和 metadata;
  2. 恢复或重建索引;
  3. 从正确 checkpoint 重放增量;
  4. 比较文档数、chunk 数、删除 tombstone 和 ACL 覆盖;
  5. 运行黄金评测集与越权测试;
  6. 达到一致水位后再接流量。

索引快照和数据库快照若不在同一水位,恢复后可能引用不存在的 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 是否失控、回填任务是否与线上共用计费配额。 最终要定位到版本和调用阶段,而不是笼统归因于“用户变多”。

一套可落地的生产检查顺序 ​

  1. 明确事实源、数据所有者、版本字段和删除语义。
  2. 建立接入状态机、幂等键、内容哈希和可恢复 checkpoint。
  3. 设计 document/version/chunk/embedding 血缘与 current pointer。
  4. 先实现更新、删除、撤权传播,再扩大数据量。
  5. 把 tenant 与 ACL 过滤固定在服务端检索入口。
  6. 分开设计四类缓存及主动失效事件。
  7. 给一次查询建立跨服务 trace,并保护敏感内容。
  8. 定义新鲜度、删除延迟、可用性、质量和成本 SLO。
  9. 用代表性数据测容量、回填速度和 ACL 低选择率性能。
  10. 按双索引流程演练一次 embedding 升级与回滚。
  11. 做备份恢复、CDC 断点、索引损坏和权限泄露演练。
  12. 把评测、变更记录和审计证据纳入发布门禁。

关键理解 ​

企业级 RAG 的可靠性不来自某个“企业版向量库”,而来自几个可以验证的约束:

  • 每次变更可重复处理,且不会产生重复最终状态;
  • 每条证据有版本、有效期、权限和完整血缘;
  • 新版本完整后再切换,旧版本能回滚且最终会回收;
  • 删除和撤权比普通更新更快、更严格地传播;
  • 缓存命中不能绕过新鲜度和授权;
  • 每次回答都能通过 trace 解释用了什么数据和模型;
  • 索引是派生物,系统始终具备从事实源重建的能力。

面试中如果被问“怎么把 RAG 上生产”,不要只回答部署向量数据库和加 Redis。 先说事实源、增量变更、版本切换和权限边界,再谈缓存、观测、成本和灾备,答案才形成完整闭环。

先看一个小场景 ​

制度从 v3 更新到 v4 后,员工马上问“报销上限是多少”。系统既要让 v4 尽快可检索,又要在重建失败时保留可回滚的 v3;员工离职后,旧缓存还不能继续展示。版本、ACL、索引任务和缓存失效必须放在同一条可追踪的更新链路里。

读完后可以模拟一次制度版本切换和员工离职,检查索引、缓存和 ACL 是否沿同一条链路更新。

持续学习,持续实践。