RAG 架构:从跑通的脚本到能上线的系统
生产级 RAG 项目的骨架不是"一条从 PDF 到答案的脚本",而是把入库管道、检索服务、生成层各自关进独立模块,再用稳定的 ID 与 schema 把它们连起来。
RAG 架构设计:从跑通的脚本到能上线的系统
修订说明(核实于 2026-08-13,东八区) 本篇复核后修正了两处会造成真实生产事故的设计,以及若干过度承诺:
- 🔴 ID 设计(第五节):原文写「内容变了 → ID 变了 → 覆盖旧的」——这是错的。ID 变了只会插入新记录,旧记录原封不动继续参与检索。已补上差集删除这一必需步骤,并拆开
doc_id/version_id/content_hash/chunk_id四个字段,指出 Python 内置hash()不能用作持久 ID、字符串拼接的边界歧义、chunk_index的位移问题,以及只比对正文哈希会漏掉的三类更新。- 🔴 ACL 接口(第七节):原文让调用方把权限当普通
filters传入——这允许忘传、传宽、伪造三种无声越权。已改为从已验证的身份上下文派生权限条件,并补上默认拒绝、AND 语义、纵深防御。- 向量缓存只用内容哈希会跨模型错误复用,key 必须带模型指纹。
- 「每个阶段都是纯函数」改为「可重跑且幂等」——采集/向量化/写入本就有副作用。
- 「索引名含模型即可物理防混用」「统一 RetrievalService 就无法绕过」「有引用即有依据」「阈值+Prompt 即可拒答」四处过度承诺已收敛。
- PostgreSQL 裸核全文检索(
ts_rank)不是 BM25,已与pg_search区分。锁定版本:与具体产品无关,涉及 PG 的部分参照
pg_search(ParadeDB)当前版本。
阅读提示 本笔记的前置阅读是RAG概念。那篇回答"RAG 是什么、每个组件为什么存在、什么时候不该用",本篇只回答一个问题:决定要做之后,系统该怎么分层。
默认读者已经理解切块、Embedding、向量检索与混合检索的机制,正在用 Python 搭一个真实的 RAG 项目。
本篇不重复讲效果为什么不好、怎么建评测基线、CRAG / Self-RAG / GraphRAG 如何取舍——那些是调优问题,见RAG优化方案。本篇只管结构问题。
⚠️ = 常见陷阱 🆚 = 对比说明 💡 = 机制或选择建议
目录
- 一、为什么脚本跑通了却不能上线?
- 二、系统应该分成哪几层?
- 三、入库管道怎么切分阶段?
- 四、检索服务应该对外暴露什么?
- 五、ID 与数据模型怎么定?
- 六、Embedding 模型一致性在工程上怎么保证?
- 七、权限过滤为什么必须下沉到检索层?
- 八、生成层怎么实现引用与拒答?
- 九、项目结构与落地顺序
- 速查表
- 复习重点
核心概念 生产级 RAG 项目的骨架不是"一条从 PDF 到答案的脚本",而是把入库管道、检索服务、生成层各自关进独立模块,再用稳定的 ID 与 schema 把它们连起来。
一条主线贯穿全篇:每一层只应该知道自己那一层的事。 业务代码不该知道向量库是 Milvus 还是 pgvector,检索服务不该知道答案要用什么语气回答,入库管道不该知道谁有权限读它写入的内容。
一、为什么脚本跑通了却不能上线?
一个能跑通的最小 RAG 通常长这样:
docs = load("manual.pdf")
chunks = split(docs)
store = VectorStore.from_documents(chunks, embedding_model)
answer = llm.invoke(prompt(store.search(question), question))这段代码作为学习是完全正确的。它不能上线,不是因为写得不好,而是因为它没有为下面这六件事留出位置:
| 真实需求 | 脚本版的处境 | 需要哪一层负责 |
|---|---|---|
| 文档更新了 | 只能整库重建,几十分钟起步 | 入库管道 + 稳定 ID |
| 批量入库中途失败 | 100 个文件挂在第 87 个,不知道该从哪重来 | 入库管道 + 状态记录 |
| 要换 Embedding 模型 | 换完系统照常运行,但召回悄悄变成随机 | 索引元数据 + 启动校验 |
| 不同用户能看的资料不同 | 无处安放,只能全库开放 | 检索层前置过滤 |
| 答案错了 | 不知道是没召回、排序错、还是模型没用证据 | 分层 + 每层可观测 |
| 要从 Chroma 换成 Milvus | 所有调用点都要改 | 检索服务收口 |
💡 分层的目的 分层不是为了让目录变好看,而是让更新、重跑、换模型、控权限、排障、换存储这六件事各有归属。判断一个 RAG 项目结构是否合格,就看这六件事能不能各自指到一个明确的模块。
二、系统应该分成哪几层?
┌────────────────────────────────────────┐
接口层 │ API:鉴权 · 会话 · 限流 · 流式输出 │
└────────────────────┬───────────────────┘
▼
┌────────────────────────────────────────┐
编排层 │ QA Service:改写 → 检索 → 组装 → 生成 │
└───────┬────────────────────────┬───────┘
▼ ▼
┌───────────────────────────────┐ ┌──────────────────────┐
服务层 │ Retrieval Service │ │ Generation Service │
│ retrieve(query, filters, k) │ │ Prompt · 引用 · 拒答 │
│ ├ 查询改写 │ └──────────────────────┘
│ ├ 向量召回 + 关键词召回 │
│ ├ 元数据 / 权限过滤 │
│ ├ 融合去重 │
│ └ 重排 │
└───────────────┬───────────────┘
▼
┌───────────────────────────────┐ ┌──────────────────┐
存储层 │ 向量库 · 关键词索引 · 关系库 │ ◄──── │ Ingestion 管道 │
└───────────────────────────────┘ │ 解析→清洗→切块 │
│ →向量化→写入 │
└──────────────────┘
┌────────────────────────────────────────┐
横切 │ 配置 · 日志 · 追踪 · 指标 · 评测 │
└────────────────────────────────────────┘读图要点有三个:
- Ingestion 是独立入口,不在问答链路上。 它是离线任务(脚本、定时任务或消息队列消费者),和 API 层没有调用关系,只通过存储层与查询侧交汇。
- Retrieval Service 是一堵墙。 查询改写、双路召回、过滤、融合、重排全部关在它内部,对外只有一个方法。
- 横切能力不写进任何一层内部。 日志和追踪穿过全部层次,因此单独放。
后面每一节展开其中一个方块。
三、入库管道怎么切分阶段?
管道分六个阶段,设计目标是让每个阶段尽可能接近纯函数:给定输入得到同样的输出,可单独测试、可单独重跑。
⚠️ 说"每个阶段都是纯函数"是不准确的——采集要读网络和文件系统、向量化要调外部 API(还可能因模型更新而输出漂移)、写入要改数据库状态,这三个阶段本质上都带副作用。真实的性质是「可重跑且幂等」:重跑一次不会产生重复数据、不会破坏已有状态。这比"纯函数"弱,但它才是你能真正依赖的保证,也正是上面 ID 设计要解决的问题。
把"纯"当成事实会让人省掉幂等性设计;把它当成目标,才会去问"这一步重跑两次会怎样"。
| 阶段 | 输入 | 输出 | 失败时怎么办 |
|---|---|---|---|
| 1. 采集 | 文件路径 / URL / 数据库连接 | 原始字节 + 来源信息 | 记录失败源,跳过,不阻塞整批 |
| 2. 解析 | 原始字节 | 结构化文本(保留标题层级、表格、页码) | 该文件标记为 parse_failed,可换解析器重试 |
| 3. 清洗 | 结构化文本 | 干净文本 | 规则问题,应当可回归复现 |
| 4. 切块 | 干净文本 | 文本块列表(带块内位置信息) | 纯计算,失败即代码 bug |
| 5. 向量化 | 文本块列表 | 向量列表 | 可重试,注意限流与批量大小 |
| 6. 写入 | 块 + 向量 + 元数据 | 索引记录 | 必须幂等,重跑不产生重复 |
关键设计一:中间产物要落盘
⚠️ 六个阶段串成一条龙,不保存中间结果 错误操作: 写成
parse → clean → split → embed → write一路直通的单个函数,只有最终结果进库。实际结果: 想把
chunk_size从 500 调到 800 看看效果,必须把几百个 PDF 重新解析一遍;解析恰恰是整条管道里最慢、最容易失败的一步。调一次参数等半小时,实验根本做不起来。原因: 把"很少变的昂贵步骤"和"经常调的廉价步骤"耦合在了同一次执行里。
正确做法: 解析结果与清洗结果落盘(存成 JSON 或写进关系库)。切块及之后的阶段从清洗产物开始跑,调参从分钟级降到秒级。
原始文件 ──► [解析] ──► parsed/{doc_id}.json ──► [清洗] ──► cleaned/{doc_id}.json
(慢,很少重跑) (快,偶尔重跑)
│
▼
[切块 → 向量化 → 写入]
(调参时只重跑这一段)关键设计二:失败隔离与断点续跑
批量入库必须记录每份文档的处理状态,否则中途失败只能全部重来:
文档状态:pending → parsed → chunked → indexed
│
└─► failed(记录阶段、错误信息、重试次数)有了状态表,重跑只需要挑出"未到达 indexed"的文档。这张表放在关系库里,是入库管道唯一需要的持久化状态。
关键设计三:向量化要批量且限速
向量化是唯一会调用外部服务(或占用 GPU)的阶段,也是最容易踩限流的地方:
- 批量提交,不要一个块一次请求——吞吐会差一到两个数量级
- 控制并发,给限流错误留指数退避重试
- 按内容哈希缓存,内容没变的块不重复向量化(增量更新时能省掉绝大部分开销)
⚠️ 向量缓存的 key 只用内容哈希会跨模型错误复用 这是个很容易写出来、又很难发现的 bug:
# ❌ 危险:换了模型之后,它会把旧模型的向量当成新模型的返回给你 key = sha256(text)换 Embedding 模型、模型升小版本、改归一化、加/去掉
query:前缀——文本没变,缓存命中,于是你拿到一个来自旧模型的向量,混进新模型的索引里。这正是第六节全力防范的那种静默故障,却从缓存这个后门溜了进来。缓存 key 必须包含完整的"向量生成方式"指纹:
# ✅ 把所有会影响输出的因素都编进 key fingerprint = f"{model_name}|{model_revision}|{dim}|{normalize}|{prompt_prefix}|{preprocess_ver}" key = sha256(f"{fingerprint}\x00{text}".encode("utf-8")).hexdigest()
model_revision建议用模型仓库的 commit hash 而不是v1.5这种人写的标签——同一个标签下的权重被悄悄更新过的事情是发生过的。判断标准:凡是会改变输出向量的输入,都必须进 key。 漏掉任何一项,缓存就从优化变成了污染源。
四、检索服务应该对外暴露什么?
只暴露一个方法。 这是整个架构最重要的一条边界。
class RetrievalService:
def retrieve(
self,
query: str,
filters: dict | None = None, # 权限、时间、来源、分类
top_k: int = 5,
) -> list[Chunk]:
...内部依次做这些事,调用方全部不需要知道:
query
├─► 查询改写(可选,按配置开关)
├─► 向量召回(top_n=30) ──┐
├─► 关键词召回(top_n=30) ──┤
│ ├─► 融合去重 ──► 重排 ──► 截断到 top_k ──► list[Chunk]
└─► filters 前置过滤 ────────┘🆚 抽象泄漏与抽象收口
| 泄漏的写法 | 收口的写法 | |
|---|---|---|
| 业务代码 | vector_store.similarity_search(q, k=3) |
retrieval.retrieve(q, top_k=3) |
| 加一路 BM25 | 每个调用点都要改 | 只改服务内部 |
| 换 Milvus → pgvector | 全项目搜索替换 | 只改一个适配器 |
| 加重排 | 每个调用点都要接一遍 | 只改服务内部 |
| 加权限过滤 | 极易漏掉某个调用点 → 数据泄漏 | 一处强制,无法绕过 |
⚠️ 让业务代码直接持有向量库对象 错误操作: 在 API 处理函数、Agent 工具、后台任务里各自
import vector_store然后直接查询。实际结果: 半年后要加权限过滤,得逐个排查所有调用点;漏掉一个就是越权读取,而且这种漏洞不会报错。
原因: 向量库是实现,检索是能力(见概念篇第八节)。让业务代码持有实现,等于把每个调用点都变成了必须维护的边界。
正确做法: 向量库客户端只允许在
retrieval/内部被导入。外层拿到的永远是RetrievalService。
若这个 RAG 要被 Agent 调用,暴露给 Agent 的也应该是同一个 retrieve()——Agent 不该知道底层是不是混合检索,见Agent 架构设计。
五、ID 与数据模型怎么定?
ID 设计决定了"能不能增量更新"。它必须在写第一行入库代码之前定好。
两个 ID 的生成规则
⚠️ 本节原来的方案有一个会导致旧内容永久残留的错误,先看这里 原文写的是:
chunk_id = hash(doc_id + chunk_index + chunk_content) 内容变了 → ID 变了 → 覆盖旧的 ← ❌ 这一步是错的ID 变了不会覆盖任何东西,只会插入一条新记录。 向量库的 upsert 是按主键匹配的:新 ID 在库里找不到对应行,于是走插入路径。旧 ID 那一行原封不动地留着,继续参与检索。
后果: 你改了一份制度文件里的一句话,重跑入库。新句子进去了,老句子也还在。用户提问时,两个版本的内容都可能被召回,LLM 拿到互相矛盾的上下文——而系统一切正常,没有任何报错。文档改得越多,库里的"僵尸块"越多,检索质量持续下滑却查不出原因。
根因: 内容哈希只能回答"这块变没变",不能回答"哪些旧块该删"。要做到后者,必须显式做差集删除:
# 重建这份文档时 old_ids = set(db.query_chunk_ids(doc_id=doc_id)) # 库里现有的 new_ids = set(c.chunk_id for c in new_chunks) # 本次生成的 client.upsert(collection, new_chunks) # 写入/更新 client.delete(collection, ids=list(old_ids - new_ids)) # ← 关键:删掉不再存在的
old_ids - new_ids这一步是整个增量更新的核心,没有它,前面的哈希设计全部白做。这也是为什么第五节末尾强调「向量库之外还要一张关系表」——你需要一个地方能可靠地查出"这份文档当前有哪些 chunk_id"。
修正后的 ID 方案,拆成四个各司其职的字段:
doc_id = 文档的稳定业务身份,跨版本不变
来源:业务主键(最佳)> 规范化后的稳定路径
⚠️ 不要用文件内容哈希——改一个错别字,文档就"换了身份",
旧 doc_id 下的所有块从此成为找不到主人的孤儿
⚠️ 也不要用会变的路径:文件一改名,整份文档就重新入库一遍
⚠️ 自增 ID 本身没问题——只要它持久化在你的文档台账里、
重跑时是查出来而不是重新生成的。真正致命的是"每次重跑都变"
version_id = 这份文档的第几版,用于灰度、回滚、按版本清理
content_hash = 文档级内容指纹,只用来回答"这份文档要不要重新处理"
chunk_id = 稳定的块标识chunk_id 的生成有两个坑要绕开:
# ❌ 别这么写
chunk_id = hash(doc_id + str(chunk_index) + chunk_content)
# ✅ 这么写
import hashlib, json
def make_chunk_id(doc_id: str, version_id: str, offset: int, text: str) -> str:
payload = json.dumps(
{"doc": doc_id, "ver": version_id, "off": offset, "text": text},
ensure_ascii=False, sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()坑一:Python 内置 hash() 不能用作持久 ID。 从 Python 3.3 起,字符串哈希默认带每进程随机盐(PYTHONHASHSEED)。同一个字符串在两次运行里得到的 hash() 值不同——你的"确定性 ID"每次重启都会变,等于回到了随机 ID。持久化标识一律用 hashlib 里的加密哈希(SHA-256)或 UUIDv5。
坑二:直接字符串拼接有边界歧义。 doc_id="a" + chunk_index=12 和 doc_id="a1" + chunk_index=2 拼出来都是 "a12",两个不同的块撞成同一个 ID。上面用 json.dumps(sort_keys=True) 做结构化序列化就没有这个问题——任何时候把多个字段拼成一个 key,都要用带分隔或结构化的编码。
坑三:chunk_index 会让前面插一段就全盘位移。 在文档开头加一个自然段,后面所有 chunk 的序号 +1,于是所有 ID 全变——差集删除会把整份文档删掉重建,历史统计、人工标注、缓存全部失效。改用字符偏移量 offset 会稳一些(只有改动点之后的偏移变化),但仍不能完全避免。真要稳定,得引入内容锚点(标题路径 + 段内序号)。
⚠️ 只比较
chunk_id集合,还漏掉三类必须更新的情况 有了差集删除,是不是"哈希一样就能跳过"?不是。以下三种情况文本一字未改,但记录仍必须更新:① 元数据变了。 ACL 标签、租户归属、有效期、审核状态、
updated_at——这些都不在chunk_content里,哈希察觉不到。权限变更被跳过是安全事故:一份文档被收紧了权限,你的增量任务因为"内容没变"跳过了它,无权限的人继续能检索到。② 向量的生成方式变了。 换了 Embedding 模型、模型升了小版本、改了归一化、加了 query/document 前缀、调整了预处理——文本相同但向量不同,旧向量不能复用。
③ 重复块被集合去重了。 同一段文本在文档里出现两次(页眉、免责声明、重复条款),
set会把它们合并成一个,于是数量和位置信息丢失。相同文本从第 2 章移到第 7 章,页码、父标题、相邻关系全变了,而哈希完全相同。解法:把"指纹"拆成几个独立维度,各自判断该做什么:
指纹 覆盖什么 变了该做什么 retrieval_text_hash参与检索的正文 重新切块 + 重新向量化 metadata_hash页码、标题路径、时效等 只更新标量字段,向量可复用 acl_hash权限标签、租户 只更新权限字段,必须立即生效 embedding_fingerprint模型名+版本+维度+归一化+前缀 全量重新向量化 关键是把「复用向量」和「保留这条索引记录」分成两个独立决策——大量场景下向量可以复用,但记录必须更新。
这套规则换来的是:重跑入库不会产生重复数据,能识别哪些块真的变了,且旧版本内容会被真正清理掉。 若 chunk_id 是随机的,每次重跑都会往库里灌一份新副本,检索结果被自己的历史版本占满。
元数据字段规范
元数据字段应当在项目开始时统一约定,而不是各个 loader 各写各的。否则过滤条件写不出来——你不知道这个字段到底叫 source 还是 file 还是 path。
| 字段 | 必填 | 用途 |
|---|---|---|
doc_id |
✅ | 关联回源文档 |
chunk_index |
✅ | 块在文档中的序号,用于还原上下文顺序 |
source |
✅ | 文件名或 URL,展示给用户 |
title / section |
✅ | 所属标题,既用于展示也可拼进块内容提升召回 |
page |
建议 | 页码,溯源精度 |
created_at / updated_at |
建议 | 时效性过滤,处理"以最新版本为准" |
doc_type |
建议 | 分类过滤(制度 / FAQ / 合同 / 手册) |
acl |
按需 | 权限标签,见第七节 |
embedding_model |
✅ | 见第六节 |
🆚 为什么向量库之外还要一张关系表
| 向量库 | 关系库 | |
|---|---|---|
| 擅长 | 相似度检索 | 事务、复杂条件查询、聚合统计、外键 |
| 存什么 | 向量 + 正文 + 检索必需的元数据 | 文档清单、处理状态、块与文档的关系、审计日志 |
| 典型问题 | "跟这句话最像的 20 个块" | "这份文档一共切了多少块,哪些块入库失败了" |
向量库不适合回答"某份文档现在有几个块"这类问题。把文档台账和处理状态放在关系库,向量库只保留检索必需的部分,两边靠 doc_id / chunk_id 关联。
六、Embedding 模型一致性在工程上怎么保证?
概念篇指出:建库与查询必须使用同一个 Embedding 模型,否则系统照常运行但召回接近随机。这一节是它的工程答案。
这类故障的可怕之处在于它不会报错——只要维度对得上,代码路径完全正常。所以不能靠"记得别搞错",必须靠机制。
三道防线
第一道:把模型标识写进索引名。
collection_name = f"kb_{tenant}_{model_name}_{model_version}_{dim}"
# 例:kb_default_bge-m3_v1.5_1024模型不同 → 索引名不同 → 写错名字时最常见的表现是查不到或报 collection 不存在,比静默返回乱序结果好得多。
⚠️ 但别把它当成"物理隔离"。它防的是配置漂移(改了模型却忘了改索引),防不住手滑写对了名字却用错了模型——名字只是个字符串,没有任何机制校验它和实际加载的模型一致。所以它必须配合下面第二道防线一起用,单独一道不够。
第二道:启动时校验。
服务启动时读取索引的元信息,与当前配置的 Embedding 模型比对,不一致直接拒绝启动。宁可启动失败,也不要带着错误配置默默提供服务。
第三道:查询侧和入库侧共用同一个模型工厂。
config.embedding ──► get_embedding_model() ──┬──► Ingestion 管道
(唯一入口) └──► Retrieval Service不允许任何一侧自己 new 一个模型出来。配置只有一份,两侧必然一致。
换模型的正确流程
⚠️ 原地替换 Embedding 模型 错误操作: 改掉配置里的模型名,重启服务,打算"新数据用新模型,旧数据慢慢迁"。
实际结果: 新旧向量混在同一个索引里,相似度比较毫无意义。而且系统不报错,可能几周后才从用户投诉里发现。
原因: 不同模型的向量处于不同的语义空间,同一个索引里的向量必须来自同一个模型。这是全量替换,没有增量迁移这一说。
正确做法: 走蓝绿流程——
1. 用新模型建一个新索引(名字自然不同) 2. 从落盘的清洗产物重新向量化写入(不必重新解析 PDF ← 第三节的收益) 3. 用评测集对比新旧索引的召回效果 4. 切流量,观察,保留旧索引一段时间以便回滚评测集怎么建见RAG 优化方案。
七、权限过滤为什么必须下沉到检索层?
企业场景里,不同用户能看到的资料不同。这个过滤只能发生在检索阶段。
✅ 正确:过滤前置,无权限的块根本不会被召回
query + acl_filter ──► 检索(带过滤条件)──► 只有有权限的块 ──► 模型
❌ 错误一:召回后再过滤
query ──► 检索 top 5 ──► 过滤掉无权限的 ──► 可能只剩 1 条甚至 0 条
(名额被无权限内容占掉,有权限的好内容排在第 6 位却拿不到)
❌ 错误二:靠 Prompt 约束
query ──► 检索全部 ──► 全给模型 ──► Prompt 里写"不要提到 X 部门的内容"
(资料已经进入模型上下文,约束不可靠且无法审计)第二种错误尤其危险:资料一旦进入模型上下文,就已经泄漏了。 提示词约束是软约束,可以被绕过,也无法向安全审计交代。权限属于代码层的硬控制,不属于自然语言。
⚠️ 但「让上层把权限当普通 filter 传进来」是不安全的
本节原来的落地方式是:retrieve() 的 filters 参数由上层根据当前用户身份注入,检索服务本身不认识"用户"这个概念。这个设计有一个明确的漏洞:权限成了调用方的可选参数。
# ❌ 危险签名:安全性完全依赖每一个调用方都记得传、且传对
def retrieve(query: str, filters: dict) -> list[Chunk]: ...
retrieve(q, filters={"dept": user.dept}) # 老老实实传
retrieve(q, filters={}) # 忘了传 → 全库可见
retrieve(q, filters={"dept": "*"}) # 传太宽 → 越权
retrieve(q, filters={"dept": attacker_controlled}) # 伪造 → 横向越权三种失败方式——忘传、传宽、伪造——没有一种会报错。新同事加一个后台任务、一个调试接口、一个批量导出脚本,都可能成为绕过点。而权限漏洞的特点是:平时完全看不出来,直到有人发现自己能搜到不该看的东西。
正确做法:让权限过滤从可信身份上下文派生,而不是从参数接收。
# ✅ 接口只接受"业务过滤条件",权限由服务自己从身份上下文推导
def retrieve(
query: str,
principal: Principal, # ← 来自已验证的认证上下文,不是调用方随手构造的字典
biz_filters: dict | None = None, # ← 业务过滤:分类、时间范围等
) -> list[Chunk]:
acl_filter = build_acl_filter(principal) # 服务内部推导,调用方无法干预
final = combine_and(acl_filter, biz_filters) # 两者是 AND,业务条件只能收窄不能放宽
return self._store.search(query, filter=final)四条不能省的约束:
principal必须来自已验证的认证结果(解析并校验过的 token / session),不是调用方自己填的用户 ID。签名上接受一个user_id: str等于没有防护——调用方填谁就是谁。- 权限条件与业务条件必须是 AND,且业务条件只能进一步收窄。允许调用方的 filter 与权限条件做 OR 或覆盖,等于把门开着。
- 默认拒绝。推导不出权限(
principal缺失、ACL 数据未同步)时应当返回空结果或直接报错,不能默认放行。这一条决定了故障时是"查不到"还是"全泄漏"。 - 单靠这一层不够。 服务内的检查只在代码走这条路径时有效。纵深防御还需要:数据库层的行级安全(RLS)或独立凭据、网络隔离、以及针对越权的自动化测试(用 A 用户的身份去搜 B 用户的独有文档,断言召回为空——这条测试要进 CI)。
「统一走 RetrievalService 就无法绕过」是过度承诺 第四节说「向量库客户端只允许在
retrieval/内部被导入」,这是好的约定,但它是约定,不是边界。Python 的 import 限制靠的是 code review 和 lint 规则,绕过成本几乎为零——直接from pymilvus import MilvusClient就行。真正的强制边界来自运行时无法逾越的东西:独立的数据库凭据(应用侧账号根本没有全表读权限)、网络可达性(向量库只对检索服务开放)、RLS、审计日志。架构分层负责让正确的事情容易做,这些机制才负责让错误的事情做不到。两者都要有。
八、生成层怎么实现引用与拒答?
概念篇讲了"要标注来源、要允许拒答",这里是具体做法。
引用:给上下文编号,让模型输出编号
[1] 来源:员工手册.pdf 第 12 页
员工入职满一年后可享受五天年假。
[2] 来源:考勤制度.pdf 第 3 页
年假需提前三个工作日申请。Prompt 中要求模型在陈述后标注它依据的编号(如 ...可享受五天年假[1])。返回给前端时,把编号回填成结构化的来源列表:
answer: "入职满一年可享受五天年假[1],需提前三个工作日申请[2]。"
sources: [ {id:1, source:"员工手册.pdf", page:12, snippet:"..."},
{id:2, source:"考勤制度.pdf", page:3, snippet:"..."} ]编号是从检索结果的元数据生成的,不是让模型自己写文件名。 让模型写来源等于允许它编造来源——这是最难被察觉的一类幻觉。
⚠️ 编号引用防的是"编造文件名",不是"引用错配" 别把这套机制读成"答案有引用 = 答案有依据"。它只保证引用指向的文件是真实存在且真的被检索到了,不保证那句话真的由这段引用支持。
模型完全可能:把
[1]标在一句[2]才支持的话后面;给一句两段资料都没说的话随手标个[1];或者引用真实存在、但被它曲解了原意。这类错误比编造文件名更难发现——因为来源点开是真的,看起来非常可信。想真正验证「claim 是否被引用支持」,需要额外一步:把每条陈述和它所引的片段单独送去做蕴含判断(NLI 模型,或一次专门的 LLM 判定),不支持的就打回或标注为存疑。这就是 RAG 流水线 双集合与评估 里 Faithfulness 类指标在做的事——它是一项独立工作,不是编号机制的附赠品。
拒答:让"不知道"成为一条正常路径
拒答需要两层配合:
- 检索层:设相关性阈值。重排分数全都低于阈值时,直接返回空结果,不必调用模型(顺便省一次调用)。
- 生成层:Prompt 明确要求"资料不足以回答时,只回答不知道,不得使用资料以外的知识",并给出固定的拒答话术。
⚠️ 这两层都不可靠,别把它们当成拒答的完整方案 第一层的问题:阈值本身很脆。 相似度分数的绝对值随 Embedding 模型、查询长度、领域语料整体漂移,换个模型阈值就失效;而且检索分高只说明"文本像",不说明"能回答这个问题"——一段和问题高度同主题、却恰好不含答案的文字,分数会很高。反过来,一个措辞刁钻但答案确实在库里的问题,分数可能整体偏低而被误拒。
第二层的问题:Prompt 是软约束。 「资料不足时只回答不知道」是请求,不是保证。模型在压力下(用户追问、上下文里有似是而非的内容)依然会编。这一点和第七节说的「靠 Prompt 做权限约束不可靠」是同一个道理。
要做到可交付的拒答,还需要至少加上:
- 答案后验校验:生成完成后,检查每条陈述是否被上下文蕴含,不被支持就转为拒答(见上方引用校验);
- 拒答话术结构化:让拒答走一条独立的返回路径(如
answer=None, reason="insufficient_context"),而不是靠字符串匹配"我不知道"来判断——模型的拒答措辞每次都不一样;- 在评测集里显式包含"应当拒答"的问题,把拒答率和误拒率当成指标持续观测。没有这一条,你永远不知道拒答机制是在正常工作还是已经悄悄失效。
允许拒答确实是可信度的必要条件,但它不是充分条件——真正的可信度来自"拒答机制本身被验证过"。
Prompt 要作为模板管理
Prompt 不该硬编码在业务函数里。它是会被反复修改和对比的配置,应当集中放在 prompts/ 目录,带版本号,并把版本号写进请求日志——否则效果变化了无法归因到是哪次改动。
九、项目结构与落地顺序
目录
app/
├── api/ # FastAPI 路由、鉴权、SSE 流式输出
├── ingestion/
│ ├── loaders/ # 按来源分:pdf / web / db / office
│ ├── cleaners/ # 规则清洗,一类规则一个文件
│ ├── splitters/ # 切块策略
│ ├── pipeline.py # 阶段编排与断点续跑
│ └── state.py # 文档处理状态表
├── retrieval/
│ ├── rewriter.py # 查询改写(可开关)
│ ├── vector.py # 向量召回
│ ├── keyword.py # BM25 / ES 召回
│ ├── fusion.py # 融合去重
│ ├── reranker.py # 重排
│ └── service.py # 对外只暴露 retrieve()
├── generation/
│ ├── context.py # 编号、拼接、token 预算
│ ├── prompts/ # 模板 + 版本号
│ └── service.py # 生成、引用回填、拒答
├── embeddings/
│ └── factory.py # 唯一的模型入口(第六节)
├── storage/
│ ├── vector/ # 向量库适配器
│ ├── keyword/ # 关键词索引适配器
│ └── relational/ # 文档台账、状态、审计
├── eval/ # 评测集与指标(详见优化方案笔记)
├── observability/ # 日志 · 追踪 · 指标
└── config/这是一份架构提案,不是官方规范 LangChain / LlamaIndex 只提供 Loader、Splitter、VectorStore、Retriever 这几层抽象,不规定项目怎么分目录。上面的划分是工程约定——小项目完全可以先把
generation/合进api/、把fusion.py和reranker.py并进service.py,等真正变厚了再拆。唯一建议从第一天就独立的是retrieval/service.py这个收口点,它是后面所有能力的挂载位置。
技术栈
按项目阶段分三档参考:
| 层 | 学习阶段 | 中小型项目 | 大规模生产 |
|---|---|---|---|
| 接口 | 脚本 / Notebook | FastAPI | FastAPI + 网关 |
| 解析 | PyMuPDF | PyMuPDF + python-docx | 专用解析服务 |
| 框架 | LangChain | LangChain / LlamaIndex | 按需,或自己组装 |
| Embedding | BGE-M3 / 在线 API | BGE-M3(自托管) | 自托管 + 批处理服务 |
| 向量库 | FAISS / Chroma | PostgreSQL + pgvector | Milvus / Qdrant |
| 关键词 | 无 | pg_search(真 BM25) |
Elasticsearch |
| 重排 | 无 | BGE Reranker | BGE Reranker(独立服务) |
| 关系库 | SQLite | PostgreSQL | PostgreSQL |
| 缓存 | 无 | Redis | Redis |
| 观测 | 结构化日志 | LangSmith / OpenTelemetry |
💡 选型的两条经验 ① pgvector 的性价比被低估了。 数据量在百万块以内、且项目已经在用 PostgreSQL 时,pgvector 让你在同一个事务里写向量和业务数据,省掉双写一致性这个大麻烦。别默认跳到 Milvus。 ② 关键词检索不一定要装 Elasticsearch。 但要分清 PostgreSQL 的两种能力:
- 裸核的全文检索(
tsvector+ts_rank)不是 BM25。 它没有 IDF,也不做默认的文档长度归一化,且中文需要额外的分词方案(zhparser/pg_jieba)。- 想要真 BM25,要装
pg_search(ParadeDB)扩展,它内置 jieba 等中文 tokenizer,评分算法就是 BM25。两者都在 PG 进程内、都省掉一个中间件,但检索质量不在一个层次。选型细节见 混合检索选型,中文分词见 BM25 算法原理。
落地顺序
| 阶段 | 做什么 | 完成标志 |
|---|---|---|
| 1 | 单文件跑通全链路 | 一个问题能拿到带来源的答案 |
| 2 | 拆出 Ingestion 管道,中间产物落盘 | 调切块参数不需要重新解析 |
| 3 | 定 ID 规则与元数据规范 | 重跑入库不产生重复数据 |
| 4 | 收口 RetrievalService |
业务代码不再出现向量库对象 |
| 5 | 加关键词召回 + 融合 | 型号、错误码能被精确召回 |
| 6 | 加重排 | 召回可以放大到 20~30 而不污染上下文 |
| 7 | 加引用回填与拒答 | 答案可验证,且会说"不知道" |
| 8 | 建评测集与追踪 | 出错能定位到具体层 |
| 9 | 权限过滤 / 增量更新 / 查询改写 | 有真实需求驱动时才做 |
💡 判断该不该进入下一阶段 不是按时间推进,而是看当前阶段的痛点是否已经出现。没有多租户就不必先做权限,文档一年不变就不必先做增量索引。 唯一建议提前做的是第 3 阶段的 ID 规则——它是所有增量能力的地基,且是全篇补起来最贵的一项:改 ID 规则意味着整个索引重建。
速查表
| 模块 | 解决什么问题 | 不该承担什么 |
|---|---|---|
| Ingestion 管道 | 把原始资料变成可检索的块 | 判断谁有权限读 |
| 文档状态表 | 断点续跑、失败定位 | 充当检索索引 |
| 中间产物落盘 | 让调参从分钟级降到秒级 | 充当最终数据源 |
| ID 规则 | 幂等重跑、增量更新 | 表达业务语义 |
| 元数据规范 | 过滤、溯源、时效 | 参与语义匹配 |
| Embedding 工厂 | 保证两侧模型一致 | 决定用哪个模型(那是配置) |
| Retrieval Service | 屏蔽召回策略与存储实现 | 决定答案怎么措辞 |
| 融合 + 重排 | 让"召回多"不等于"上下文吵" | 找回压根没召回的内容 |
| Generation Service | 组装上下文、回填引用、拒答 | 决定检索怎么实现 |
| 向量库 | 相似度检索 | 事务、聚合、复杂条件查询 |
| 关系库 | 台账、状态、审计 | 相似度检索 |
🆚 与其他三篇笔记的分工
| 问题 | 去哪篇找 |
|---|---|
| RAG 是什么、每个组件为什么存在 | RAG概念 |
| 什么时候不该用 RAG | 概念篇第十一节 |
| 决定要做了,代码怎么分层、怎么选型 | 本篇 |
| 效果不好,怎么定位到哪一层、怎么调 | RAG优化方案 |
| 建了评测集,指标怎么算 | 优化方案第二节 |
| 关键词检索的算法细节与中文分词 | BM25 算法原理 |
复习重点
可直接复述的结论
- 分层的目的是让更新、重跑、换模型、控权限、排障、换存储这六件事各有归属。
- Ingestion 是离线入口,不在问答链路上,只通过存储层与查询侧交汇。
- 管道各阶段的目标是可重跑且幂等(不是严格意义的纯函数——采集、向量化、写入都有副作用);解析与清洗的产物必须落盘,否则调一次切块参数要重解析全部 PDF。
- 批量入库必须有文档状态表,否则中途失败只能全量重来。
chunk_id必须用 SHA-256 之类的加密哈希确定性生成(Python 内置hash()带进程随机盐,不能用),且字段要结构化序列化后再哈希。用 uuid4 等于放弃增量。 5b. 确定性 ID 只解决一半问题,另一半是差集删除。 ID 变了是新增不是覆盖——重建时必须执行delete(old_ids - new_ids),否则旧版本内容会永久残留在库里继续被召回。 5c. 指纹要拆成四份:retrieval_text_hash/metadata_hash/acl_hash/embedding_fingerprint。只比对正文哈希会漏掉权限变更和换模型这两类必须更新的情况。- 检索服务只暴露
retrieve(),向量库客户端不许离开retrieval/目录——但这是约定不是边界,真正的强制来自独立凭据、网络隔离、RLS 和越权测试。- Embedding 一致性靠机制而非纪律:把模型标识写进索引名 + 启动时校验 + 共用模型工厂,三道一起用;换模型是蓝绿重建,没有增量迁移。向量缓存的 key 也必须带模型指纹,否则它就是绕过前三道防线的后门。
- 权限必须前置过滤,且过滤条件从已验证的身份上下文派生,不从调用方参数接收。让上层传
filters会带来忘传、传宽、伪造三种无声的越权;权限条件与业务条件必须是 AND,推导不出权限时默认拒绝。- 引用编号来自检索结果的元数据,绝不能让模型自己写来源。但编号只防"编造文件名",不保证这句话真的被该引用支持——那需要独立的蕴含校验。
- 拒答是检索层阈值 + 生成层话术两层配合,低分时可以完全不调用模型。但两层都不牢靠(阈值随模型漂移、Prompt 是软约束),还需要答案后验校验、结构化的拒答返回路径,以及评测集里显式的"应当拒答"用例。
- Prompt 是带版本的配置,不是硬编码的字符串;版本号要进日志才能归因。
- 落地按痛点推进,但 ID 规则要在第一天定好,它是补起来最贵的一层。
自测问题
- 为什么说"能跑通的脚本"和"能上线的系统"差的不是代码量?
- 解析产物不落盘,会在什么时候第一次让你后悔?
chunk_id用uuid4()生成,重跑一次入库会发生什么?- 业务代码里直接写
vector_store.similarity_search(),半年后加权限过滤时会遇到什么? - 为什么把 Embedding 模型名写进索引名,比在代码里加一句校验更可靠?
- "先召回 top 5,再把没权限的过滤掉"——这个做法错在哪?
- 为什么绝对不能让模型自己在答案里写出处文件名?
- 什么情况下 pgvector 比 Milvus 更合适?
- 检索服务内部加了重排,调用方需要改代码吗?为什么?
- 要把 Embedding 从 BGE-large 换成 BGE-M3,完整流程是什么?哪一步因为第三节的设计而变便宜了?