企业文档问答 Agent 实践:从简单检索到混合检索的架构演进

做一个文档问答 Agent,最初的链路并不复杂:读取文件,把文字存起来,检索相关内容,再交给模型回答。但真正开始连续提问、批量处理文件后,问题才逐渐显现:文件明明存在却搜不到,命中了原文却说信息缺失,连续聊几轮后请求越来越大。

这篇文章记录一个基于 Hermes、通过飞书交互的数字资产 Agent 的演进过程。重点是每次调整背后的判断:问题发生在哪一层,原方案为什么不够用,以及新方案解决了什么。

1. 先建立一条可以追溯的问答链路

第一版围绕一个简单目标展开:用户发送文件并确认归属后完成入库,之后可以用自然语言查询,答案附带来源。

文件接收 → 内容解析 → 分块 → 原文与元数据存储
                                ↓
用户提问 → 检索相关片段 → 模型依据片段回答 → 答案与来源

这里已经具备 RAG,也就是检索增强生成的基本结构。模型负责理解问题和组织答案,资料依据来自检索。

我们把文件标题、项目、客户、访问范围以及页码、工作表等定位信息与原文一起保存。这样既能限制查询范围,也能说明答案来自哪里。资料归属允许具体项目、公司资料和待分类,避免“没有项目名称就无法入库”。

不过,链路能跑通只是起点。随后需要分别检查:文件是否提取完整、相关片段是否被召回、传给模型的证据是否完整,以及最终回答有没有超出证据。

2. 集中存储如何选型:为什么选择 SQLite + FTS5

文件解析后,需要统一保存的不只是正文,还有文件与片段的对应关系、归属信息、来源位置和处理状态。查询要能筛选这些信息,文件更新时也要能找到并处理旧记录。因此,选型首先围绕“资产主库”展开:怎样集中管理这些有关联的数据,并为检索提供可靠原文。

对这个需求,可以从以下几类方案比较:

方案适合承担的职责本项目的取舍
SQLite + FTS5结构化资产记录、片段原文、关键词索引嵌入应用运行,适合当前单机部署方式,减少独立服务维护
PostgreSQL / MySQL 等服务型关系数据库集中管理关联数据,供多个应用实例访问多实例共享数据时值得考虑;当前阶段需要额外部署和维护数据库服务
Elasticsearch / OpenSearch 等搜索引擎全文检索和搜索结果组织可作为专用搜索层,但仍需明确资产状态、更新流程与主库的职责
向量数据库相似度检索和语义候选召回作为检索补充,不让向量索引承担全部资产管理职责

我们的选择是 SQLite + FTS5。SQLite 统一保存资产元数据和 chunk 原文,FTS5 为文本建立全文索引;查询时结合归属和访问范围筛选,通过索引获得有限候选,再进行后续排序。这里的“集中”指统一管理资产记录,并不要求数据库必须独立部署成一个网络服务。

选择依据是当前部署形态和维护成本,而不是认定 SQLite 在所有规模下都更好。文件数量只是一个维度,还要看分块总量、正文大小、查询并发和写入频率。若后续需要多个实例共享写入,或持续出现写入竞争,就需要重新评估服务型数据库。

这个方案解决了资产集中管理和关键词候选查找,但用户不一定使用原文中的词提问。语义召回的需求,进一步推动了混合检索架构的选择。

3. 从关键词到混合检索:精确匹配和语义匹配各有所长

用户未必知道文档里的原话。比如用户问“断网后还能不能工作”,文档可能写的是“支持离线运行和本地缓存”。两者语义接近,直接按词匹配却可能漏检。

Embedding 为这个问题提供了补充:把片段和问题编码为向量,通过相似度寻找语义相关的内容。

但单独改用向量也有不足。型号、版本号、接口名称等问题,需要精确区分;语义相似的片段可能讨论同一类设备,却没有回答用户问的那个具体版本。

我们的判断是保留两条召回路径:

召回方式主要作用需要注意的问题
FTS5 关键词召回查找名称、术语和明确出现的词对不同表达方式可能漏检
向量语义召回补充用词不同但含义相关的片段可能召回话题相近但不能作答的内容

两路结果不能直接把分数相加,因为关键词得分和向量相似度不在同一尺度。当前实现采用 RRF,按片段在各路结果中的排名融合:

融合得分 = Σ 1 / (k + 当前召回路径中的排名)

同一个 chunk 在两路中出现时合并为一条;在多路中都靠前的片段,会获得更高的融合排名。这里的去重针对同一个 chunk,并不等于已经解决了不同文件之间的重复内容。

实际检查检索结果时,我们还发现,重复资料会占用有限的证据位置。假设返回五条结果,其中四条来自同一段内容的不同副本,列表看起来很丰富,提供的有效信息却很少;真正能补充答案的片段反而可能排在后面。

这让我们开始区分“召回数量”和“有效信息量”。混合检索增加了找到资料的机会,但还需要检查结果是否重复、是否覆盖问题的不同方面。当前已实现的是按 chunk 标识合并两路结果;跨文件的内容去重和来源多样性控制,是由这个现象推导出的进一步改进方向。多样性也不能机械地理解为“每个文件只取一条”,因为同一文档里的多个片段可能恰好共同构成完整依据。

最终形成以下职责划分:

解析与分块
  ├─ SQLite:资产信息、原文、归属、访问范围、处理状态
  │    └─ FTS5:关键词候选
  └─ LanceDB:Embedding 向量与语义候选
                   ↓
           合并候选、融合排序
                   ↓
           读取有效原文与来源
                   ↓
               模型回答

SQLite 继续作为资产主库,LanceDB 负责向量检索。向量候选需要关联回主库,检查资产状态和查询范围,再成为回答证据。向量服务异常时,检索可以退回关键词路径,并保留降级信息。

引入向量也带来了新成本:除了生成 Embedding,还要维护原文与向量的对应关系。文件更新后,如果只更新原文、没有处理旧向量,就可能召回过期内容。两套索引的一致性因此成为入库流程的一部分。

4. 分块与截断:先分清答案在哪里丢失

一次问答中,系统已经检索到相关文档,却回答“后续内容被截断,无法确认”。排查后发现:完整 chunk 已经存入数据库,检索也命中了,但提供给回答环节的内容用了固定长度的开头预览,真正答案位于预览之后。

问题出在证据传递阶段。重新解析文件,或者单纯改变分块大小,都不能修复这个位置上的信息丢失。

我们把两个用途拆开:

{
  "preview": "用于展示的短预览……",
  "content": "用于回答的完整片段",
  "locator": "page:3"
}

preview 可以限制长度;模型回答使用完整 content。最终展示来源时只需文件名和定位信息,不必把全部证据重复发送给用户。

分块本身则是另一个需要处理的问题。规则和例外、任务和负责人、表头和数据行,都可能被边界分开。即使每个片段都完整传给模型,只召回其中一半,也可能缺少理解所需的上下文。

当前分块参数以约 900 字符、120 字符重叠作为工程起点。相邻片段共享部分文本,降低信息刚好落在边界附近时被割裂的概率,并保留解析阶段提供的来源定位。

这个参数不是通用最优解:

  • 片段太小,容易丢失语境,同一问题可能需要多个片段才能回答。
  • 片段太大,会带入更多无关内容,也增加模型输入。
  • 重叠能改善连续性,同时会增加存储、向量计算和重复召回。

固定窗口也不能自动保住复杂表格、跨页段落或章节关系。评价分块时,需要检查“一个完整意思是否被保留”,再决定是否需要按段落、章节或表格结构进一步调整。

这次排查让我们形成了一套沿证据链定位问题的方法。遇到错误答案时,先保留同一个问题和对应原文,再逐层核对:

检查位置要确认的问题对应的处理方向
解析结果答案是否已被提取,结构是否丢失检查解析器、OCR 和结构保留方式
数据库原文提取出的答案是否完整存入检查分块与写入流程
检索候选及最终排名含有答案的片段是否召回,是否进入最终证据检查过滤条件、召回方式和排序
模型实际输入命中的内容是否完整传递检查字段选择、截断和上下文处理
最终回答模型是否忠实使用证据,有没有遗漏或扩写检查回答约束和证据覆盖

这也解释了为什么那次修复落在证据传递层:数据库和召回结果已经证明答案存在。沿着这条链路定位后,才有依据决定是否需要换解析器、调整分块、修改检索,或者改进模型回答方式。修复后再用原问题回归,才能确认改动确实作用于故障环节。

5. 为什么不能把所有证据一直留在上下文里

完整证据解决了当前问题的准确性,却暴露出多轮会话的体积问题。

工具结果通常会进入对话历史。后续每次调用模型时,之前的问题、回答和检索结果可能再次进入请求。即使新问题只需要少量资料,旧证据仍会持续占用上下文。

此外,同一份检索结果里曾同时包含完整 content、短 preview、重复的 snippet,以及再次拼接预览的 answer。这让同一段文字在一次结果中就出现了多次。

我们先做无需改变证据内容的精简:保留完整正文、一份预览和必要来源字段,删除重复文本。然后调整历史压缩策略,让较早的工具输出和对话更早进入压缩流程。

当前使用 Hermes 内置的阈值式压缩:达到阈值后裁剪旧工具结果、总结较早对话,并保护近期消息。它不是每次回答结束就立即清理证据;消息条数也不严格等于问答轮数,因为一轮中可能调用多个工具。

这两步分别控制单次结果的重复量和多轮累积量。用户追问旧问题的具体依据时,应重新检索原文,不能把历史摘要当成新的事实来源。

同样,召回更多片段也不应直接等于向模型提供更多片段。候选数量服务于召回覆盖,最终证据数量服务于回答。把这两个阶段分开,才有空间在覆盖率、相关性和输入成本之间做选择。

6. 从单文件成功到批量可恢复:导入需要状态

单文件处理可以是一条同步调用,但批量任务会遇到损坏文件、解析耗时过长、Embedding 请求失败和进程中断。简单循环一旦退出,很难知道哪些文件真正完成了。

为此,我们为目录文件维护处理清单,并分别记录内容处理与向量处理状态。文件被发现、文本已解析、向量已写入,代表不同阶段,不能用一个笼统的“成功”替代。

内容指纹用于识别重复和变化;失败项可以重试;续跑时根据状态恢复,避免整批从头计算。增量处理则关注新增、修改和消失的文件,而不是每次无差别重建全部资料。

这里需要同时考虑两种情况:同样内容出现在不同路径,以及同一路径的内容发生变化。只用路径判断,会漏掉内容更新;只看内容指纹,又不能忽略资料归属和访问范围。

压缩包进一步放大了这个问题。一个入口文件可能展开成大量成员,外层体积无法直接代表后续解析和向量化工作量。因此,处理压缩包需要先获得成员清单,再对深度解析的规模设置限制,超出范围时保留清单信息。

长时间停留在“处理中”也不必然意味着卡死。判断是否中断,需要结合 CPU 活动、分块数量增长和处理日志。状态告诉我们任务在哪个阶段,运行指标才说明这个阶段是否仍在推进。

7. 最后一公里:交互可靠,证据可验证

检索之外,还有一些问题会直接影响使用体验。

用户先发附件,再回复“保存到某项目”,是一个连续意图。系统需要关联引用消息、附件名称和实际缓存文件,才能把自然对话转成一次正确入库。要求用户反复提供路径,会把系统内部问题转嫁给用户。

不同格式也需要不同解析入口。PDF、Word、Excel、PPT 和图片并不是同一种文本容器;图片 OCR 提取的是可识别文字,不能直接视为对图中全部语义的理解。提取完成后,还要尽可能保留页码、工作表和幻灯片等信息,帮助检索结果回到原始位置。

输出端则遇到过 Markdown 兼容问题:标题、加粗和表格有时直接显示为原始符号。最终采用普通文字、中文编号和明确换行,让消息即使以纯文本方式展示也能阅读。

回答质量还有一个容易混淆的地方:检索分数高,不等于答案可信。分数反映的是片段与问题的匹配程度。一份资料可能多次出现某个产品名称,因此排名靠前,却完全没有记载用户询问的售价。两路同时命中,也不能直接证明片段包含答案。

我们因此把证据覆盖作为评估回答的标准:将问题拆成需要确认的事实,逐项检查原文能否支持。例如,用户问“何时发布、售价多少”,资料只提供发布日期,回答就应给出日期并明确售价未记载。这个例子中的日期有依据,价格没有依据,不适合用一个笼统的高置信度概括整个回答。

当前工具中的置信度仍属于基于召回方式和分数的启发式信号,并不是经过校准的答案正确率。评估时需要将它与逐项证据核对分开;这项判断标准也不能写成已经具备了自动证据覆盖评分能力。

这些问题需要通过完整链路测试发现。我们把验证问题分成以下几类:

测试场景主要检查什么
答案位于片段末尾回答阶段是否仍错误使用短预览
相关信息靠近分块边界是否保留了足够的上下文
换一种说法提问语义召回是否补充了关键词遗漏
查询具体型号或版本是否混入相似但不对应的资料
跨文件综合问题每项结论是否有对应来源
文档未记载的信息是否明确说明无法确认
连续追问是否正确理解指代并重新获取必要证据
文件更新后再次查询是否出现过期原文或旧向量结果

评估时,我们沿着“期望原文—实际召回—模型输入—最终回答”逐层检查。这样一次失败才能转化为明确的工程问题:是解析遗漏、召回不足、排序不合适、证据传递丢失,还是生成环节超出了依据。

架构也就在这些具体问题中逐步形成:FTS5 改善候选查找,向量补充不同表达的召回,完整片段支撑回答,历史压缩控制累积输入,状态管理让批量导入可以恢复。每个组件都对应一个可以观察、可以测试的问题,后续调整也有了可追溯的判断依据。

8. 当前系统架构与适用场景

把上述模块放在一起,当前系统分为资料入库和问答检索两条主链路,由 Hermes 承接飞书交互和工具调用:

当前系统架构

数字资产 Agent · 当前系统架构

资料入库与问答检索共享资产主库;关键词与向量互补,回答使用完整证据。

数字资产 Agent 入库与问答架构 左侧为文件解析、分块和索引写入,右侧为关键词与向量召回、排名融合和模型回答。SQLite 保存原文和元数据,LanceDB 保存向量。 飞书用户 ↔ Hermes 数字资产 Agent 附件归档 · 自然语言提问 · MCP 工具调用 01 / 资料入库02 / 问答检索 飞书附件 / 本地资料目录MCP 入库工具 / 批量与增量任务 格式解析与文字提取PDF · Word · Excel · PPT · 图片 OCR 等 分块与重叠处理保留正文、来源定位与归属信息 SQLite 资产主库资产元数据 · chunk 原文 · 处理状态 FTS5 全文索引关键词索引关联原文与元数据 Embedding 服务文档片段向量化↓ LanceDB 向量索引 入库过程管理内容指纹 · 重复识别 · 文件变化检测内容与向量状态分开记录 · 失败重试与续跑原文和向量通过 chunk 标识对应 用户问题 → MCP 查询工具结合项目、客户与访问范围 FTS5 关键词召回检索名称、术语等有限候选进入后续排序 问题 Embedding↓ LanceDB 语义召回补充不同表达的匹配 关联原文与范围校验向量候选关联 SQLite,检查资产有效状态 RRF 排名融合按 chunk 标识合并两路结果 有限候选的完整正文与来源content 用于回答,preview 仅作预览 Agent 调用问答模型完整证据 + 会话历史 → 组织答案历史达到阈值后压缩,保留近期消息 飞书纯文本回答 → 用户直接答案 · 原文来源 · 未明确内容 共享存储:SQLite + FTS5 + LanceDB左侧写入原文与索引,右侧读取候选与证据;Embedding 与问答模型分别配置

当前系统仍在持续完善。适用于中小企业单机、低并发的基础资料归档与问答;实际容量需结合资料类型、分块总量与硬件验证。

图中的 Embedding 用于文档和问题的向量化,问答模型负责理解与表达,两者分别配置。资产原文与处理状态保存在 SQLite 中,向量索引补充语义查找;会话历史帮助理解追问,具体事实仍需要回到检索证据中确认。

目前系统仍在持续完善,但基础的文件归档、批量处理、混合检索和带来源问答链路已经形成。对于以常见办公资料为主、采用单机部署、查询并发不高的中小企业,当前架构能够承接基础的资料管理与知识查询需求,作为起步方案是够用的。

这里的“够用”指功能与部署复杂度适合这类场景,不代表对任意文件数量和并发量作出性能承诺。具体使用容量还要结合分块总量、文件类型、硬件资源和模型服务吞吐进行验证。后续完善也围绕实际使用反馈展开,重点提升检索质量、重复资料处理和运行稳定性。