跳到主要内容
Cowers://
全部文章
检索与 RAG

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优化方案。本篇只管结构问题。

⚠️ = 常见陷阱 🆚 = 对比说明 💡 = 机制或选择建议

目录


核心概念 生产级 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 管道  │
        └───────────────────────────────┘        │  解析→清洗→切块  │
                                                  │  →向量化→写入    │
                                                  └──────────────────┘
                    ┌────────────────────────────────────────┐
      横切          │  配置 · 日志 · 追踪 · 指标 · 评测        │
                    └────────────────────────────────────────┘

读图要点有三个:

  1. Ingestion 是独立入口,不在问答链路上。 它是离线任务(脚本、定时任务或消息队列消费者),和 API 层没有调用关系,只通过存储层与查询侧交汇。
  2. Retrieval Service 是一堵墙。 查询改写、双路召回、过滤、融合、重排全部关在它内部,对外只有一个方法。
  3. 横切能力不写进任何一层内部。 日志和追踪穿过全部层次,因此单独放。

后面每一节展开其中一个方块。

三、入库管道怎么切分阶段?

管道分六个阶段,设计目标是让每个阶段尽可能接近纯函数:给定输入得到同样的输出,可单独测试、可单独重跑。

⚠️ 说"每个阶段都是纯函数"是不准确的——采集要读网络和文件系统、向量化要调外部 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=12doc_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)

四条不能省的约束:

  1. principal 必须来自已验证的认证结果(解析并校验过的 token / session),不是调用方自己填的用户 ID。签名上接受一个 user_id: str 等于没有防护——调用方填谁就是谁。
  2. 权限条件与业务条件必须是 AND,且业务条件只能进一步收窄。允许调用方的 filter 与权限条件做 OR 或覆盖,等于把门开着。
  3. 默认拒绝。推导不出权限(principal 缺失、ACL 数据未同步)时应当返回空结果或直接报错,不能默认放行。这一条决定了故障时是"查不到"还是"全泄漏"。
  4. 单靠这一层不够。 服务内的检查只在代码走这条路径时有效。纵深防御还需要:数据库层的行级安全(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 类指标在做的事——它是一项独立工作,不是编号机制的附赠品

拒答:让"不知道"成为一条正常路径

拒答需要两层配合:

  1. 检索层:设相关性阈值。重排分数全都低于阈值时,直接返回空结果,不必调用模型(顺便省一次调用)。
  2. 生成层: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.pyreranker.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
观测 print 结构化日志 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 算法原理

复习重点

可直接复述的结论

  1. 分层的目的是让更新、重跑、换模型、控权限、排障、换存储这六件事各有归属。
  2. Ingestion 是离线入口,不在问答链路上,只通过存储层与查询侧交汇。
  3. 管道各阶段的目标是可重跑且幂等(不是严格意义的纯函数——采集、向量化、写入都有副作用);解析与清洗的产物必须落盘,否则调一次切块参数要重解析全部 PDF。
  4. 批量入库必须有文档状态表,否则中途失败只能全量重来。
  5. 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。只比对正文哈希会漏掉权限变更和换模型这两类必须更新的情况。
  6. 检索服务只暴露 retrieve(),向量库客户端不许离开 retrieval/ 目录——但这是约定不是边界,真正的强制来自独立凭据、网络隔离、RLS 和越权测试。
  7. Embedding 一致性靠机制而非纪律:把模型标识写进索引名 + 启动时校验 + 共用模型工厂,三道一起用;换模型是蓝绿重建,没有增量迁移。向量缓存的 key 也必须带模型指纹,否则它就是绕过前三道防线的后门。
  8. 权限必须前置过滤,且过滤条件从已验证的身份上下文派生,不从调用方参数接收。让上层传 filters 会带来忘传、传宽、伪造三种无声的越权;权限条件与业务条件必须是 AND,推导不出权限时默认拒绝。
  9. 引用编号来自检索结果的元数据,绝不能让模型自己写来源。但编号只防"编造文件名",不保证这句话真的被该引用支持——那需要独立的蕴含校验。
  10. 拒答是检索层阈值 + 生成层话术两层配合,低分时可以完全不调用模型。但两层都不牢靠(阈值随模型漂移、Prompt 是软约束),还需要答案后验校验、结构化的拒答返回路径,以及评测集里显式的"应当拒答"用例。
  11. Prompt 是带版本的配置,不是硬编码的字符串;版本号要进日志才能归因。
  12. 落地按痛点推进,但 ID 规则要在第一天定好,它是补起来最贵的一层。

自测问题

  1. 为什么说"能跑通的脚本"和"能上线的系统"差的不是代码量?
  2. 解析产物不落盘,会在什么时候第一次让你后悔?
  3. chunk_iduuid4() 生成,重跑一次入库会发生什么?
  4. 业务代码里直接写 vector_store.similarity_search(),半年后加权限过滤时会遇到什么?
  5. 为什么把 Embedding 模型名写进索引名,比在代码里加一句校验更可靠?
  6. "先召回 top 5,再把没权限的过滤掉"——这个做法错在哪?
  7. 为什么绝对不能让模型自己在答案里写出处文件名?
  8. 什么情况下 pgvector 比 Milvus 更合适?
  9. 检索服务内部加了重排,调用方需要改代码吗?为什么?
  10. 要把 Embedding 从 BGE-large 换成 BGE-M3,完整流程是什么?哪一步因为第三节的设计而变便宜了?