一个知识库问答 Demo,通常只需要串起三步:切分文档、检索片段、调用模型。真正上线以后,问题会变得具体得多:答案明明在文档里,模型为什么还是答错?用户刷新页面,任务为什么消失?工具请求超时后,能不能直接重试?在线人数上来以后,先撑不住的是连接、数据库,还是模型额度?
这些问题横跨信息检索、分布式系统和模型应用。单独记住 RAG、Rerank、Checkpoint 等名词,并不足以解释它们之间的关系。
这篇文章以一个企业知识库助手为例:用户上传文档,围绕文档多轮提问,必要时让系统查数据、调用工具、生成报告。沿着这个任务,说明检索如何提供证据、上下文如何组织证据、Agent 如何推进执行,以及系统怎样在失败后继续工作。
文中的候选数量、预算和容量数字都是用于解释设计的示例,需要根据真实数据评测和压测,不能直接当成生产配置。
1. 先分清知识流、执行流和状态
系统可以先按下面的职责划分。图中的模块是逻辑边界,小项目完全可以部署在同一个服务里。
文档上传 → 解析与清洗 → 结构化切分 → 全文索引 / 向量索引
↑
用户 → API / 鉴权 → 创建 Run → 检索与上下文构造 → 模型
│ │
│ 工具调用 ←┘
│ │
│ 保存结果,继续执行
↓
Run 状态 / Checkpoint / 事件
↓
SSE → 客户端
这里有两个容易混淆的事实。
首先,RAG 和 Agent 可以组合,但并不要求彼此存在。固定的“检索后回答”可以是一条 Workflow;开放式调查任务可以由 Agent 决定下一次查什么,检索只是它的一个工具。
其次,即使一次模型调用只依赖传入的消息,整个应用仍然有状态。用户是谁、当前任务执行到哪里、哪个工具已经成功、预算还剩多少,都必须由系统管理。模型上下文只是其中一部分。
因此,设计时先回答三个问题:知识从哪里来,任务怎样推进,执行事实保存在哪里。组件选型放在这些问题之后。
2. 知识入库:解析质量决定检索的上限
文档入库的第一步是解析。把 PDF 直接转换成一大段字符串,可能丢掉标题层级、表格关系、图注和页码。之后即使 Embedding 很强,也无法恢复已经丢失的事实。
更合适的中间表示是“文档—章节—内容块”。内容块可以是段落、表格、图片或代码,同时保留来源位置。一个切分后的片段可以包含:
{
"chunkId": "refund-v3-section2-01",
"documentId": "refund-policy",
"documentVersion": 3,
"sectionPath": ["售后政策", "退款条件"],
"page": 12,
"content": "购买后 14 天内且产品未激活,可以申请退款。",
"effectiveFrom": "2026-01-01",
"tenantId": "tenant-a",
"aclRef": "policy-sales"
}
正文用于检索与回答;文档版本用于处理新旧冲突;页码和位置用于引用;租户与权限信息用于访问控制。它们应当在入库时建立关联。
清洗也要克制。重复页眉、导航栏通常是噪声,但表格里的单位、合同里的否定词、页下注释中的例外条件,可能直接影响答案。清洗目标是去掉干扰并保留语义。
同一份知识可以建立全文索引和向量索引。更新文档时,还要考虑两个索引何时可见:如果全文索引已经是 v3,向量索引仍然是 v2,一次查询就可能混入互相矛盾的版本。
一种可行设计是给索引构建分配版本,完成后再切换可查询版本,并让每个候选保留文档版本。删除和权限撤销也必须同步影响索引与缓存,不能只修改原始文件。
3. Chunk 的关键是检索粒度与语义完整性
固定长度切分很容易实现,但它不理解一句话是否说完,也不理解规则与例外之间的关系。
例如,退款规则包含两个条件:购买时间在期限内、产品未激活。如果切分边界刚好落在两者之间,只召回其中一块,就可能给出错误结论。
不同切分方式有不同用途:
| 方式 | 适合的内容 | 需要注意的问题 |
|---|---|---|
| 固定长度 | 结构较弱、需要快速建立基线的文本 | 容易切断句子和条件 |
| 递归切分 | 普通长文,按段落、句子逐层拆分 | 分隔符未必代表完整语义 |
| 结构感知 | 技术文档、合同、带标题的 Markdown | 依赖解析质量,要处理过长章节 |
| 语义切分 | 主题切换明显、结构不稳定的内容 | 多一步计算,效果仍需评测 |
Overlap 可以缓解边界问题,但会增加存储、召回重复和上下文开销。它也无法保证相距很远的条款被一起召回。
对结构清楚的长文档,可以采用 Parent–Child Retrieval:把较小片段用于检索,命中后补充所属小节或必要的相邻块。这样既保持定位精度,也保留回答所需的上下文。补充范围仍然要受 token 预算约束,不能一命中就把整章塞给模型。
文档长到几十万字时,还可以建立文档摘要、章节摘要和原始片段的层次索引。层级路由能够缩小搜索范围,但如果第一步选错章节,后续就没有机会找到答案。因此最好保留跨章节搜索或全局检索的回退路径。
检索时使用的粒度,可以小于生成答案时提供的粒度。 这个区分比固定一个 Chunk 大小更有用。
4. 混合检索:让精确匹配与语义匹配互补
用户问“电脑死机怎么办”,文档写的是“计算机无响应的处理方式”,向量检索可能识别两者的语义联系。用户输入 ORA-00942、订单编号或 API 名称时,词项匹配往往更直接。
BM25 的主要因素包括词频、逆文档频率和文档长度归一化。词频贡献会逐渐饱和;越少见的词通常越有区分度;长度归一化则缓解长文档仅靠词频占优的问题。Lucene BM25Similarity 文档 实际效果也依赖分词器、字段设置和索引粒度,不能把“用了 BM25”当成精确匹配一定成功。
向量检索把 Query 和内容映射为向量,按选定的距离或相似度寻找候选。大规模检索通常采用近似最近邻算法,需要在召回率、延迟和索引成本之间取舍。查询向量与文档向量必须遵循同一检索模型的编码约定;模型升级时也要规划重新嵌入和索引切换。
两路结果融合时,不宜直接把原始分数相加。BM25 的 12.8 与向量相似度的 0.86 没有天然一致的尺度。可以在评测数据上校准分数,也可以先使用基于名次的 Reciprocal Rank Fusion:
RRF(d) = Σ 1 / (k + rank_i(d))
这里的求和只覆盖文档 d 出现过的结果列表,名次从 1 开始,k 是平滑常数。例如取 k = 60,一个结果在两路分别排名 1 和 2,它的分数就是 1/61 + 1/62。RRF 避免了不同原始分数尺度的直接对齐,但也舍弃了分差信息,并不保证在所有数据集上最优。Elasticsearch RRF 文档
完整流程可以是:
当前问题 + 必要历史
↓
按需消解指代、改写 Query
↓
带租户和权限约束的检索
├── BM25 Top 50
└── Vector Top 50
↓
合并、去重、RRF
↓
候选 Top 30
↓
Rerank
↓
上下文选择与引用绑定
Query 改写也不是每次必需。用户问“那它为什么变慢了”,需要从历史中补出对象;用户已经提供完整错误码时,额外改写可能增加延迟,甚至改掉关键术语。应当保留原始问题,并记录改写前后的内容。
权限过滤必须由服务端根据可信身份生成,并尽量下推到两路检索中。在把内容交给外部重排服务或模型前,再检查访问资格。把未授权内容先交给模型、最后才隐藏引用,已经太晚。
5. Rerank 提高排序精度,也会成为延迟来源
常见的 Bi-Encoder 分别编码 Query 和文档,因此文档向量可以提前计算。Cross-Encoder 则同时读取 Query 与候选内容,通过两者之间的交互进行相关性评分,适合对少量候选重新排序。它需要逐对处理查询与候选,不适合在大规模语料上直接穷举。Sentence Transformers:Retrieve & Re-Rank
这也是“先召回,再重排”的原因。重排器只能改善已经召回的候选,不能找回被前面漏掉的证据。
如果 Rerank 太慢,先看候选数、候选长度和队列等待时间。可以减少重复候选,按问题难度调整数量,限制明显无关的长文本,再评估批处理、较小模型、量化或推理后端优化。
候选越少也不总是越好。剪枝过早会丢证据;批处理过大则可能提高吞吐,却让低流量请求等待更久。优化应同时观察检索质量和尾延迟。
缓存键至少应区分 Query、文档内容版本和重排模型版本。请求得到缓存结果后,仍然要执行当前权限检查。
重排超时可以退回混合检索排序,但这是一种质量降级。普通资料查询可能接受;对证据要求严格的流程,可能应该明确失败或要求补充信息。降级策略需要记录进 Trace,才能在之后解释质量变化。
6. 检索命中以后,还要保证证据真正进入答案
“召回正确,回答错误”并不矛盾。正确片段可能在 Rerank 后掉出候选,也可能在组装 Prompt 时被截断,或者与过期文档一起进入上下文,最后被模型错误解释。
排查时需要看到以下几个集合:
检索得到的候选
↓
重排后的候选
↓
实际选入上下文的证据
↓
最终发送的模型输入
↓
回答中的结论与引用
Context Builder 的职责就是完成中间这几步:去重,补齐必要上下文,选择文档版本,处理冲突,控制 token,并给片段绑定可追溯的引用标识。
如果 v2 写“7 天退款”,v3 写“14 天退款”,不能只按相似度选择。系统需要结合生效时间、用户问的是当前规则还是历史规则,以及来源的权威性决定用哪个版本。无法消解时,就应当把冲突明确呈现出来。
对于模型容量,预算可以写成:
系统规则 + 工具定义 + 历史摘要 + 近期消息 + 检索证据
+ 为输出及模型所需额外 token 预留的空间
≤ 对应模型的有效上下文限制
具体 token 计数和输入、输出限制应按模型接口核对。超出限制时,有的接口会报错,有的系统会在发送前截断;不能假设总会由模型自动处理。
《Lost in the Middle》在其测试模型和任务中观察到:相关信息的位置会影响长上下文表现,中间位置可能更难被有效利用。这说明窗口长度并不等于有效利用能力,但不能据此断言所有新模型都具有相同程度的位置偏差。论文原文
工程上应优先减少无关信息,保留完整条件,测试证据排序。压缩和摘要可以节省 token,但也可能遗漏否定、数值或例外条款,因此要保留到原始证据的映射。
引用同样需要验证:一条链接能够打开,只能证明来源存在;还需要检查它是否真的支持相邻结论。涉及计算时,可以让程序基于提取出的数据运算,再让模型解释结果。
7. 长对话与多模态文档,需要不同的组织方法
保存完整历史,不代表每轮都要把完整历史发送给模型。聊天记录承担审计和产品展示职责,Prompt 则服务于当前推理。
一个可用的上下文组合是:稳定规则、当前任务摘要、必要的长期事实、最近几轮对话、当次检索证据。任务摘要应重点保留用户目标、约束、已完成动作、失败原因、未完成事项和证据位置。
“用户希望联系客户”和“邮件已经发送成功”必须分开保存。把意图摘要成执行事实,会直接污染后续决策。关键状态应来自持久化的工具结果和业务数据,而非模型对历史的自由概括。
多模态文档也不能一律压成纯文本。表格应保留表头、单位、行列关系;图片可以用描述辅助检索,回答时再按需读取原图;图表和扫描件应保存页码与区域位置,便于核对。图片描述和 OCR 都可能出错,关键数值需要回到原始来源确认。
对于十万行销售数据,问题如果是“各地区收入合计多少”,更适合经过授权、限制和校验的 SQL 或数据分析工具。Embedding 能帮助找到相关资料,却不适合替代确定性的聚合计算。
同理,“这份合同的违约金是多少”可以定位条款;“总结整份合同的主要风险”则需要覆盖多个章节。局部事实问答与全局摘要应采用不同的信息获取策略。
8. Agent Runtime:让模型决策,让系统控制执行
Workflow 的路径主要由代码预先定义;Agent 则让模型动态决定下一步动作。固定的文档处理流程可以使用 Workflow,开放式故障调查可以在受控节点内使用 Agent,两者可以组合。Anthropic:Building effective agents
Agent 的概念循环并不复杂:
加载状态 → 构造上下文 → 调用模型
├── 最终回答 → 结束
└── 工具请求
↓
参数校验、权限校验、预算检查
↓
执行并保存结果
↓
进入下一轮
真正的工程量在循环周围。运行时需要限制总步数、工具调用次数、时间、token 和成本;为单次模型调用与工具调用设置超时;传播取消信号;记录每步状态;在无进展时停止。
例如可以检测相同工具、规范化参数和结果是否连续重复。但重复调用并不必然是死循环:查询一个正在运行的任务,本来就可能返回多次相同状态。判断必须结合工具语义、合理轮询间隔和总截止时间。
这些预算应当属于 Run,并在恢复后延续。若进程重启就把计数归零,限制就失去了意义。并行步骤还需要原子预留预算,再根据实际消耗结算,避免多个 Worker 同时看到同一份余额。
工具输入也必须逐层检查:JSON 能解析,只代表语法正确;符合 Schema,不代表业务允许;用户有权查看某文档,也不代表有权修改或外发它。执行层需要独立完成业务验证与授权。
参数修复可以把具体错误返回给模型,但应限制次数。网络抖动等可重试错误要遵守总截止时间,采用带随机抖动的退避,并在适用时尊重 Retry-After。权限拒绝和明确的非法参数,应返回可理解的失败结果,避免盲目重试。
框架可以提供这些机制的部分实现。例如 LangGraph 用 Checkpointer 保存线程范围内的图状态,并用 Store 保存跨线程数据;使用时仍要选择持久化后端,并理解哪些状态被保存、何时保存。LangGraph Persistence
9. 可靠恢复:Checkpoint 与外部副作用是两件事
可以先明确几种对象:
| 对象 | 表示什么 | 常见持久化内容 |
|---|---|---|
| Session | 长期会话容器 | 用户、会话配置、消息关联 |
| Message | 一条产品消息 | 内容、角色、附件 |
| Run | 一次任务执行 | 状态、预算、截止时间、所属会话 |
| Step | Run 中的一步 | 模型请求或工具调用、输入输出 |
| Checkpoint | 可恢复的执行状态 | 下一步位置、已完成结果、状态版本 |
| Event | 已发生的变化 | 事件序号、类型、时间、结果引用 |
| Prompt Context | 当次模型输入 | 从以上数据选择出来的临时视图 |
小系统可以用关系数据库保存核心状态和事件,用对象存储保存大文件。Redis 可用于缓存、限流和租约协调。需要跨服务分发时再加入消息系统,不必从第一天就部署所有组件。
恢复机制最难的场景是:工具已经创建订单,但 Worker 在写入 Checkpoint 前崩溃。系统只看 Checkpoint,会以为这一步还没做;重新执行就可能创建第二笔订单。
保存检查点并不能自动让外部动作只执行一次。
可行的处理方式是为逻辑动作分配稳定的操作 ID,在重试与恢复时复用它。工具端将此 ID 与结果关联:同一操作再次到达时返回已有结果,参数不一致时拒绝复用。执行前保存意图,执行后保存结果;遇到超时不确定状态时,优先查询操作结果。
仅使用 runId + stepId 也有边界:它可以防止同一步被重放,却不能阻止模型在下一步再次提出相同业务动作。订单等操作往往还需要业务层唯一键或明确的重复操作规则。
如果外部系统不支持幂等、也无法查询结果,就不能宣称已经实现端到端的 exactly-once。需要把“不确定是否成功”保存为一种状态,安排对账、补偿或人工处理。
多个 Worker 还可能争抢同一个 Run。租约可以协调所有权,但旧 Worker 在暂停后恢复时,可能已经失去租约。写状态时应检查所有权与递增版本,拒绝过期执行者;外部副作用仍需工具端幂等配合。
同一 Session 收到两个并发请求时,也应定义产品语义:排队、取消前一个,还是创建分支。让两个 Run 无约束地覆盖同一份上下文,会产生难以重现的状态错误。
10. SSE:连接断开与任务结束应当分开处理
SSE 是基于 HTTP 的服务器到客户端事件流,响应类型为 text/event-stream。事件由空行分隔,常见字段有 id、event 和 data。原生 EventSource 重连时可以发送 Last-Event-ID,但协议没有替应用保存历史事件。WHATWG Server-sent events 标准
对于需要恢复的任务,可以把创建与订阅拆开:
POST /runs
→ 创建任务,返回 runId
GET /runs/{runId}/events
→ 订阅执行事件
GET /runs/{runId}
→ 查询当前状态和结果
POST /runs/{runId}/cancel
→ 显式请求取消
原生 EventSource 适合 GET 订阅,不能像普通 Fetch 一样随意设置请求体或自定义请求头。需要 POST 流式请求时,可以使用 Fetch 读取响应流,但重连、事件解析和游标管理也要由客户端实现。
事件可以这样表达:
id: 101
event: answer.delta
data: {"runId":"run-42","text":"退款条件包括"}
id: 102
event: run.completed
data: {"runId":"run-42","resultId":"result-42"}
要实现断线续传,服务端必须保存可回放事件,或者提供结果快照与后续事件的衔接机制。客户端记录已处理序号,重连后申请补齐并去重。回放转入实时订阅时也要避免空隙,否则恰好发生在两者之间的事件会丢失。
事件保留期有限时,游标可能过期。此时应返回快照或明确的重新同步指令。客户端收到终止事件后,应结束订阅,避免完成的任务继续无意义重连。
用户断开连接是否取消任务,需要由业务决定。短对话可以在断开或宽限期后尝试取消;生成长报告的任务可以继续运行,让用户稍后查询。取消信号也只能尽力传播,不能保证已经提交到外部系统的动作被撤回。
这层还要处理代理缓冲、空闲超时、心跳和慢客户端。不要让无限增长的发送队列耗尽内存。可以合并细粒度文本事件,为连接设缓冲上限,必要时断开并让客户端重新同步。
11. 高并发与成本:先量化负载,再谈扩容
“十万用户”不是足够完整的容量要求。注册人数、同时在线人数、SSE 连接数、每秒新增 Run 和同时进行的模型调用,分别消耗不同资源。
一个粗略容量估算可以使用:
平均执行中任务数 ≈ 每秒进入执行的任务数 × 平均执行时长
例如稳定状态下每秒开始 200 个任务,平均执行 20 秒,就约有 4,000 个执行中的任务。这只是平均数,容量规划还要考虑突发流量、长尾任务、重试,以及队列是否持续增长。
接住很多连接,并不代表有能力同时生成很多答案。模型调用还受到请求速率、token 速率、并发配额和自身吞吐约束;Agent 一次 Run 又可能调用模型和工具多次。
应当分别管理入口配额、用户与租户并发、运行队列、检索容量、重排容量和模型调用额度。队列必须有边界和等待上限,否则过载只是变成用户看不见的长时间等待。
延迟也要分段观察:
接收请求 → 排队 → Query 处理 → 检索 → 重排 → 模型首 token → 完成
状态提示能让用户知道任务还在推进,但不等于答案已经更快产生。产品层的首次反馈时间、模型首 token 时间、整体完成时间应分别记录。
成本优化同样应落到具体阶段。能省略的改写就省略,能用程序计算的就交给程序,重复证据不必反复发送;模型路由必须用任务评测验证,避免便宜模型失败后重试与升级,反而增加总成本。
缓存需要区分用途。应用层的答案缓存复用业务结果;检索缓存复用候选;Prompt Cache 或推理服务的 KV Cache 复用部分计算。它们都不能替代会话持久化,也不能绕过当前权限与知识版本检查。具体缓存命中规则要按所用服务验证。
12. 用评测定位问题,也用失败场景验证系统
最终答案的一个“好/坏”分数,不足以指导改进。可以把评测拆成几层:
| 层次 | 要回答的问题 | 可观察指标或结果 |
|---|---|---|
| 检索 | 所需证据是否进入候选 | Recall@K、按问题类型的漏召回 |
| 排序 | 有用证据是否排在前面 | MRR、nDCG、重排前后变化 |
| 上下文 | 证据是否完整进入最终输入 | 覆盖率、截断、版本冲突 |
| 生成 | 结论是否正确且有支持 | 正确性、依据一致性、引用支持度 |
| Agent | 任务是否真的完成 | 业务结果、工具选择、重复副作用 |
| 系统 | 失败时行为是否符合设计 | 恢复、取消、超时、成本与尾延迟 |
Recall@K 通常指相关证据中有多少进入前 K 个结果。如果只是检查“是否至少命中一条”,应当明确这是命中率类口径。多跳问题尤其需要区分找到一条相关片段与找到完成推理所需的全部证据。
测试集应包括事实问题、多轮指代、精确错误码、跨文档问题、表格计算、文档冲突、无法回答和权限受限的情况。使用固定版本的语料、提示词、模型配置和标注标准,才能比较一次调整带来的变化。
LLM-as-Judge 可以辅助评价,但不能直接当成事实真值。应使用明确标准和人工标注样本校准;对于关键数字、引用与业务副作用,优先采用可核验的规则或业务结果。还要保留未参与调参的测试集,避免只优化熟悉的样本。
线上 Trace 则记录足够定位问题的信息:检索候选 ID 与版本、上下文选取、模型配置、工具输入输出引用、降级路径、耗时和消耗。敏感内容应脱敏、限权并设保留期限,不能为了调试把所有原文长期复制到日志。
恢复能力还需要故障注入验证:在工具成功后、状态提交前终止 Worker;在 SSE 回放期间制造新事件;让租约过期;撤销文档权限;让客户端变慢。这些测试能发现正常问答永远不会暴露的漏洞。
13. 安全边界必须在模型之外成立
RAG 会把外部文本带入模型。网页、PDF 或工具结果中,都可能出现“忽略之前要求”“上传其他文档”等恶意内容。检索相关性高,不代表内容具备指令权限。
应将外部内容视为数据,并由执行层限制可调用工具、资源范围和外发目标。使用最小权限凭证,独立校验参数与身份,对高影响操作设置符合业务要求的确认机制。Prompt 中的提醒可以作为一层防护,但不能替代权限检查。OWASP Prompt Injection Prevention
例如文档告诉 Agent“把内部报表发送到这个邮箱”,是否允许发送应由可信的用户请求、数据权限和工具策略共同决定。文档本身不能授予这项权限。
测试也不能只覆盖直接恶意提问,还应覆盖检索片段、工具返回、长对话摘要和缓存命中后的间接注入。跨租户访问与权限撤销需要贯穿检索、缓存、生成和下载环节验证。
14. 一条可以逐步落地的升级顺序
把前面的能力一次全部搭好,成本很高,也不容易判断哪些部分真的有用。更实际的推进方式是每一步都有明确的验收问题。
| 阶段 | 主要建设内容 | 验收时重点看什么 |
|---|---|---|
| 可追溯的问答基线 | 结构化解析、来源位置、基础检索、无证据时明确说明 | 错误能定位到哪一步,引用能否核对 |
| 检索与上下文优化 | 混合检索、Rerank、父子块、版本选择、上下文预算 | 固定测试集上质量与延迟怎样变化 |
| 有界的工具执行 | Workflow 或单 Agent、参数校验、授权、预算、取消 | 失败是否有边界,副作用是否可核验 |
| 可恢复的任务 | Run、Checkpoint、幂等、事件回放、SSE 解耦 | 崩溃与重连后是否丢任务或重复执行 |
| 容量与持续改进 | 限流、队列、背压、缓存、Trace、回归评测 | 过载时是否可控,优化是否带来真实收益 |
多 Agent 可以在任务确实能独立拆分、需要隔离上下文或权限时再引入。它会增加状态协调、通信和结果合并成本,也应与单 Agent 或固定 Workflow 基线比较。
面对一条错误回答,系统应当能指出证据在哪一步丢失;面对一次中断,能够说明任务是否还在运行、哪些动作已经发生;面对增长的流量,能够量化连接、计算和预算各自的瓶颈。具备这些能力以后,RAG 与 Agent 才能持续迭代,而不是每次故障都从猜测 Prompt 开始。
延伸阅读:Agent 进入 Web 后,Session、Run 和连接应该怎么分离、Agent 的会话应该保存什么:Session、Message、Event 与 Context。