从文件上传到可核对回答:一个 Go 文档问答项目的 RAG 实现

最近在做一个文档问答项目。用户上传 PDF、DOCX、文本或图片,等后台完成处理后,就可以针对资料库提问。系统返回的不只是一段答案,还包括文件名、页码、原文片段和内容哈希,方便用户回到原文核对。

为了方便行文,下文把这个项目称为「知页」。

这篇文章只回答一个问题:一份文件上传之后,怎样经过解析和索引,最终生成一个附带可核对引用的回答?

先看结论:RAG 不是一次模型调用,而是两条流水线

如果资料很少,可以把全文和问题一起发给模型。但文档一多,这种做法很快会遇到几个限制:上下文装不下、重复发送成本高、扫描件没有文本层、答案难以定位原文,以及多用户场景下的权限隔离。

因此,「知页」没有在提问时读取全部文件,而是把系统拆成两条链路:

1
2
3
4
5
6
7
文档处理链路(离线)
文件 → 解析 / OCR → 按页切片 → 写入 MySQL → 生成向量 → 写入 Qdrant

问答链路(在线)
问题 + 过滤条件 → 关键词 / 向量召回 → RRF 融合排序
→ Reranker 语义精排(可选)
→ LLM 生成回答 → 后端校验并组装引用

第一条链路把原始文件变成可检索的切片,一份文档通常只处理一次;第二条链路只取与当前问题最相关的少量切片,再交给模型回答。

这也是理解全文最重要的分界:

  • 文档处理阶段解决“材料怎样变得可检索”;
  • 在线问答阶段解决“怎样找到依据,并约束模型只基于依据回答”。

用一个具体例子串起来:用户上传一份 30 页的合同,其中第 12 页写着“剩余款项应在验收完成后 10 日内支付”。后台把这句话切成一个带页码的文本块,同时建立关键词和向量索引。用户随后询问“款项最晚什么时候支付”,系统先找回这个文本块,再让模型基于它回答。模型只返回所使用的切片编号,最终展示的文件名、第 12 页原文和哈希则由后端从检索结果中重新组装。

知页文档问答服务架构

后端使用 Go 和 Gin。MySQL 保存资料库、文档、切片、后台任务和问答记录;原文件通过对象存储抽象管理,当前实现落在本地目录;Qdrant 保存向量索引;Poppler(开源的 PDF 解析与渲染工具集)负责提取 PDF 文本和渲染页面;扫描件交给 OCR;最后再调用 LLM。

为了避免后文术语混在一起,先给出一张最小词汇表:

术语 在本文中的含义
Chunk(切片) 从某一页正文中切出的、可独立检索的一小段文本
Embedding 把问题或切片编码为稠密向量,用于比较语义相似度
Point / Payload Qdrant 中的一条向量记录,以及附带的用户、文档、页码等元数据
Recall(召回) 从大量切片中先找出一批可能相关的候选
RRF 依据多路结果的排名进行融合,不直接比较不同系统的原始分数
Reranker 同时阅读问题与候选片段,再做一次更精细的相关性排序

下面先沿着一份文档的处理过程展开。

第一条链路:把文件变成可检索的切片

1. 上传接口只负责可靠接收

上传接口不会同步解析文档,只做六件事:

1
2
3
4
5
6
7
8
9
10
11
校验资料库归属

清理文件名,检查扩展名和大小

以 UUID 对象名保存文件,同时计算 SHA-256

按用户、资料库和文件哈希查重

事务创建文档记录和 document_parse 任务

立即返回文档状态

为什么不在 HTTP 请求里直接完成解析?

因为普通文本也许几百毫秒就能处理完,扫描 PDF 却可能需要逐页渲染、OCR 和 Embedding。如果把这些工作放在上传请求中,就必须同时处理连接长时间占用、请求超时、失败重试、进度查询以及服务重启后的任务恢复。

所以这里的边界很明确:HTTP 请求负责把文件可靠地收下来,后台任务负责把文件处理完。

上传阶段还有两个容易忽略的细节。

第一,文件大小不能只相信请求头。接口会先检查客户端声明的大小,但实际保存时仍使用 io.LimitReader 多读一个字节,并在写入后再次检查对象存储统计的大小,以防 Content-Length 缺失或被伪造。

第二,查重必须有两道防线:

  • 应用层先按 user_id + case_id + file_hash 查询,其中 case_id 对应本文所说的资料库 ID;
  • 数据库再用唯一约束处理并发上传。

两个相同文件同时上传时,两个请求都可能通过第一次查询,但最终只有一个能插入成功。另一个请求识别到唯一键冲突后返回已有文档,并清理本次产生的多余对象。数据库约束在这里保证的是并发正确性,而不只是查询性能。

2. 后台处理不是简单地开一个 goroutine

文档处理被拆成两个任务:

1
2
3
4
5
document_parse
读取文件 → 解析 / OCR → 切片 → 事务替换 MySQL 切片

document_embed
分批生成 Embedding → 写入 Qdrant → 更新向量状态

拆成两个任务的原因是:正文可用和向量可用不是同一件事。

一份文档可能已经解析成功,关键词检索也已经能找到它,但 Embedding 服务或 Qdrant 暂时不可用。如果系统只有一个笼统的“处理成功/失败”状态,向量路径的故障就会让整份文档失去检索能力。

因此代码分别记录:

1
2
3
processing_status  文本解析和切片是否完成
vector_status 文档整体向量是否完成
embedding_status 单个切片的向量是否完成

这样做带来几条清晰的恢复路径:

  • 正文完成后,MySQL 关键词检索立即可用;
  • 向量化失败时,只重试 document_embed
  • 更换 Embedding 模型时,只重建向量;
  • 没有配置向量能力时,系统仍能以关键词模式工作。

后台 Runner 默认每两秒领取一次任务,最多同时处理三个任务。每个任务会记录状态、处理阶段、进度、开始和完成时间、最近心跳、重试次数与错误信息。

任务运行时默认每 15 秒更新一次心跳。超过一分钟没有心跳的 processing 任务会被视为中断并重新入队;自动重试耗尽后才标记为失败。任务中的 panic 也会被转换成失败状态,避免记录永久停在 processing

Runner 还会做一层状态修复。例如,文档处于待解析状态却没有解析任务,或者正文已经完成、向量待处理却没有 Embedding 任务,Runner 会补建缺失任务。这种 reconciliation 不能代替严格的幂等设计,但可以修复一部分“状态已写入、任务记录却丢失”的异常。

这里还要区分“任务能重试”和“任务重试后不会重复写坏数据”。心跳、超时回收和 reconciliation 解决的是任务可能丢失的问题;多个 Worker 同时处理同一任务、旧 Worker 超时后继续写入,则需要更严格的幂等与并发控制。生产环境至少应考虑:

  • 原子领取任务,并为领取结果设置租约;
  • 使用 fencing token 或任务版本,拒绝过期 Worker 的写入;
  • 根据切片稳定标识生成确定性的 Qdrant Point ID;
  • 对 MySQL 和 Qdrant 使用可重复执行的 upsert;
  • 为一次索引构建记录 generation/version,便于识别新旧数据;
  • 定期修复孤立 Point、缺失 Point 和过期索引。

本文能确认当前实现具备心跳、重试和状态修复;上面这些“重复执行安全”仍应作为需要继续验证和加强的边界,而不能仅凭存在重试机制就默认成立。

最后,请求的取消信号和截止时间必须一直传递到最底层。每个任务都使用带截止时间的 context.Context,OCR、Embedding、Qdrant 请求以及 Poppler 子进程都绑定这个上下文。否则,外层虽然已经取消或超时,底层 HTTP 请求或子进程仍可能继续消耗资源。

3. 不同文件先统一成“页码 + 文本”

解析器目前支持:

1
2
3
4
txt / md / csv / json
docx
pdf
jpg / jpeg / png / webp

不同格式的内部结构差异很大,但解析层最终只输出一种结构:

1
2
3
4
TrialCueParsedPage{
PageNumber: 1,
Text: "提取出的正文",
}

从这一层开始,后面的切片、索引和检索都不再关心输入是 PDF、Word 还是图片。不过,这里的 PageNumber 对不同格式并不完全等价:

格式 当前“页码”的含义
PDF PDF 文件中的物理页序号,可用于回到对应页面核对
图片 一张图片视为一页,页码为 1
TXT / Markdown / CSV / JSON 当前整体作为一个逻辑页处理,页码为 1
DOCX 当前正文整体作为逻辑页处理,不能可靠对应 Word 排版后的物理页码

尤其是 DOCX:word/document.xml 保存的是文档结构,不保存一套在所有环境中都稳定的分页结果。字体、纸张、页边距和渲染引擎变化,都可能让 Word 的视觉页码发生变化。如果业务要求引用“Word 第几页”,更可靠的路径是先用固定环境把 DOCX 渲染或转换为 PDF,再按 PDF 物理页解析。

普通文本会去掉 BOM 和首尾空白,并作为单页处理。DOCX 本质上是 ZIP,代码读取 word/document.xml 中的文本节点,并在段落结束处补换行。当前实现只覆盖正文,还没有完整处理表格语义、页眉页脚、批注和文本框,也缺少严格的解压大小与压缩比限制。

PDF 的处理路径更长:

1
2
3
4
5
6
7
8
9
pdfinfo 获取页数

pdftotext -layout 提取文本层

按分页符恢复页码

如果整份文本几乎为空,再进入 OCR

必要时用 pdftoppm 逐页渲染图片后识别

Poppler 是什么:Poppler 是一套开源的 PDF 解析与渲染工具。这里用到的 pdfinfopdftotextpdftoppm 都是它提供的命令行程序:pdfinfo 用来读取页数等文档信息,pdftotext 用来提取已有的文本层,pdftoppm 则把 PDF 页面渲染成图片,供后续 OCR 识别。它本身不负责 OCR。

项目没有把 Poppler 当作常驻服务,而是由 Go 通过 exec.CommandContext 按需启动这些命令。解析器限制了 PDF 处理并发和单次命令时间,避免异常文件长期占住 Worker。

PDF OCR 默认最多处理 20 页,并只对限流、超时和服务端错误等可重试异常做有限次数的指数退避。

“最多 20 页”不只是性能参数,还会影响结果完整性。例如一份 85 页扫描 PDF 如果只识别前 20 页,系统不应仍将这份文档标记为“处理完成”,否则用户会误以为全部 85 页都已进入检索范围。从当前代码只能确认 OCR 存在页数上限,无法确认产品层是否会向用户提示内容未完整处理。更稳妥的设计是二选一:超过上限直接失败并提示拆分文件,或者明确返回部分完成状态,例如:

1
2
3
processing_status = partial
processed_pages = 20
total_pages = 85

无论选择哪种方式,都不应静默丢弃后续页面。

这里仍有一个明显缺口:当前按“整份 PDF”判断是否需要 OCR。若大部分页面有文本层、只有少数页面是扫描图,这些扫描页可能被漏掉。更合理的做法是逐页判断并混合解析。

4. 切片时,先保住页码

解析结果接下来会被切成较小的文本块。当前参数是:

1
2
target_runes  = 800
overlap_runes = 100

切片按 rune 而不是字节计数,避免从一个中文 UTF-8 字符的中间截断;优先在换行、句号、问号、分号或空白处断开;相邻切片保留 100 个 rune 的重叠,降低关键信息刚好落在边界上的概率。

但 rune 数不等于模型 Token 数。不同 Embedding 和 LLM 使用不同分词器,同样 800 个 rune 可能对应不同数量的 Token。因此,rune 适合做语言无关的初步切分,却不能保证一定低于模型输入上限。更稳妥的做法是:切片完成后再使用实际模型的 tokenizer 检查长度,并在生成 Embedding 和组装 LLM 上下文时分别检查 Token 预算,必要时执行截断。

更关键的约束是:切片不跨页。

不跨页可能损失一部分跨页语义的召回效果,却能让引用页码保持稳定。这个项目要求用户可以回到原文核对,因此在“可能更高的召回率”和“更可靠的页码”之间,当前实现选择后者。

每个切片都会保存:

1
2
3
4
5
page_start / page_end
chunk_index
content
content_hash
embedding_status / embedding_model / embedding_dimension

切片写入 MySQL 后,关键词检索已经可以使用。若启用 Embedding,后台再分批生成向量,并把用户、资料库、文档、切片、页码、材料类型、证据编号、内容哈希和模型名写入 Qdrant Payload。

到这里,第一条链路完成:原始文件已经变成带页码、带权限边界、可通过关键词或向量检索的切片。

第二条链路:从问题找到依据,再生成回答

一次提问不会直接进入 LLM。它要先经过过滤、召回、融合、精排和引用校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
权限校验与过滤条件规范化

MySQL 关键词候选(默认 40)
+
Qdrant 候选(默认 40)

RRF 融合、去重(保留 20)

Reranker 语义精排(可选)

最终上下文(默认 8)

LLM 生成结构化回答

后端校验引用编号并组装原文

这里最容易混淆的是三个数量:召回 40 条、融合后保留 20 条、最终给模型 8 条。它们不是同一个 Top K:前面尽量提高召回,后面再逐步压缩上下文。

1. 先确定这次问题允许搜索哪些材料

提问接口除了 question,还支持文档、材料类型、证据编号和页码范围等过滤条件:

1
2
3
4
5
6
7
8
9
10
{
"question": "这份材料里提到的付款时间是什么?",
"filter": {
"document_ids": [12, 18],
"document_types": ["合同", "付款凭证"],
"evidence_numbers": ["证据3"],
"page_from": 2,
"page_to": 20
}
}

代码会先限制过滤项数量、去重并校验页码范围,再把同一组条件应用到 MySQL 和 Qdrant。页码采用区间相交语义:

1
2
chunk.page_end   >= page_from
chunk.page_start <= page_to

比起要求切片完全落在查询区间内,这种判断更符合“查第 2 到 20 页”的直觉。

权限条件同样必须进入每一条召回路径。关键词和向量检索都带上当前用户与资料库;Qdrant 返回切片 ID 后,服务还会回到 MySQL,按用户、资料库、过滤条件和文档状态重新加载切片。

换句话说,向量库只负责寻找候选,最终的访问权限和数据状态仍由业务数据库校验。 外部索引返回的 ID 不能直接越过权限和状态校验。

2. 关键词和向量各自负责什么

当前链路涉及三种基础检索能力:MySQL 全文检索、Qdrant 稠密向量检索,以及可选的 Qdrant 稠密与稀疏混合检索。它们又形成两个层次的融合:

  • Qdrant 内部混合检索:在 Qdrant 内融合 Dense 与 Sparse/BM25;
  • 服务层多路融合:在 Go 服务中融合 MySQL 候选与 Qdrant 返回的候选列表。

下文分别说明这些检索能力及两层融合的作用。

MySQL 负责关键词召回

MySQL 路径优先使用全文索引。代码中的核心表达式如下:

1
MATCH(content) AGAINST (? IN NATURAL LANGUAGE MODE)

可以把它拆成三部分理解:

  • MATCH(content):指定在 content 列中检索。这个列需要建立与查询匹配的 FULLTEXT 全文索引;
  • AGAINST (?):指定查询内容。这里的 ? 不是要搜索的问号,而是数据库驱动执行预编译语句时使用的参数占位符,运行时会绑定为用户的问题;
  • IN NATURAL LANGUAGE MODE:使用自然语言模式。MySQL 会对查询和文档分词、计算相关度并返回一个浮点分数,分数越高通常表示越相关。自然语言模式也是 MySQL 全文检索的默认模式,这段声明主要是在代码中把意图写清楚。

例如用户询问“款项最晚什么时候支付”,绑定参数之后,可以近似理解为:

1
MATCH(content) AGAINST ('款项最晚什么时候支付' IN NATURAL LANGUAGE MODE)

下面是为了说明语法而简化的查询,不代表项目中的完整表结构和权限条件:

1
2
3
4
5
6
7
8
SELECT
id,
content,
MATCH(content) AGAINST (? IN NATURAL LANGUAGE MODE) AS score
FROM document_chunks
WHERE MATCH(content) AGAINST (? IN NATURAL LANGUAGE MODE) > 0
ORDER BY score DESC
LIMIT 40;

这里出现了两个 ?,执行时必须把同一个问题参数绑定两次:第一次用于 SELECT 计算 score,第二次用于 WHERE 过滤无关结果。示意性的中文全文索引可以写成:

1
2
ALTER TABLE document_chunks
ADD FULLTEXT INDEX idx_content (content) WITH PARSER ngram;

这和 LIKE '%付款时间%' 不同:LIKE 主要判断原始字符串是否包含指定片段,而全文检索会基于分词和词项统计计算相关度,因此更适合从大量切片中选出候选。本文使用 ngram 分词器处理中文;它会把连续文本切成相互重叠的短词元,让没有空格分隔的中文也能进入全文索引。项目没有额外部署 Elasticsearch,主要是为了控制 VPS 的常驻资源和运维复杂度。

MySQL 版本说明:全文检索以及 MATCH ... AGAINST 能力从 MySQL 3.23.23 开始出现,最初仅支持 MyISAM;显式的 IN NATURAL LANGUAGE MODE 修饰语从 MySQL 5.1.7 开始支持;InnoDB 全文索引属于 MySQL 5.6 引入的能力;内置 ngram 全文解析器从 MySQL 5.7.6 开始提供。因此,只看这段显式语法需要 MySQL 5.1.7 或更高版本,而本文实际使用的 InnoDB + ngram + MATCH ... AGAINST 组合至少需要 MySQL 5.7.6

全文检索失败时怎样降级

全文检索报错或没有命中时,还会回退到简单 Token 检索:英文和数字按连续词切分,中文生成 2~4 字片段,使用 LIKE 找出最多 200 条候选,再在 Go 中重新计分排序。

这条兜底路径适合命中名称、编号和日期,但它依赖多次 LIKE 查询和应用层计分,数据量增大后容易出现扫描范围扩大、查询延迟升高的问题,因此不适合作为长期主方案。

Qdrant 负责语义召回

Qdrant 路径把问题转换成 Embedding,再执行稠密向量相似度检索。它主要补充关键词不容易覆盖的近义表达和概括性问题。

这里先解释两个 Qdrant 术语。Point 可以理解为向量库中的一条记录:在本文的设计里,一个文档切片对应一个 Point。Point 除了保存一组或多组向量,还带有 Payload,用于记录切片 ID、用户、资料库、文档、页码等业务元数据。

Qdrant 内部混合检索怎样工作

代码还提供可选的 Qdrant 内部混合检索。启用后,每个 Point 同时保存两种表示:

表示 如何生成 更擅长命中什么
稠密向量(Dense Vector) Embedding 模型把整段文本编码成固定长度的浮点数组 语义相近但字面不同的表达,例如“尾款什么时候付”和“剩余款项的支付期限”
稀疏向量(Sparse Vector) 使用 BM25 对文本中的词项进行编码,并以稀疏形式保存 精确名称、编号、日期和原文词项

稠密向量通常每个维度都有值,适合比较整体语义;稀疏向量的大部分维度为零,非零维度通常对应实际出现过的词项,因此更接近传统关键词检索。

查询时,同一个问题会走两条子查询:

1
2
3
4
5
6
7
问题
├─ Embedding → 稠密向量检索 → 取前 N 条
└─ BM25 → 稀疏向量检索 → 取前 N 条

Qdrant 内部 RRF

Qdrant 内部混合候选

Qdrant Query API 把这两条子查询称为 prefetch。这里的“预取”不是提前把数据加载到内存,而是先执行若干路候选查询,再把候选交给主查询融合。RRF 只根据两路结果的名次合并,不直接比较稠密相似度与 BM25 分数,因为这两类分数不在同一个尺度上。

继续使用开头的合同示例。对于“款项最晚什么时候支付”这个问题,稠密检索可能找出第 12 页“剩余款项应在验收完成后 10 日内支付”的切片;稀疏检索则更容易抓住“款项”“支付”等原词。如果同一切片在两路检索中都排名靠前,它会获得更高的融合分数,也更可能进入最终候选。

完整链路存在两层融合:

  1. Qdrant 内部 RRF:融合 Qdrant 的稠密结果和 BM25 稀疏结果;
  2. 服务层 RRF:融合 MySQL 全文检索列表与整个 Qdrant 候选列表。

因此,开启 Qdrant 内部混合检索后,服务层拿到的“Qdrant 候选”本身已经融合过一次;后面的服务层 RRF 仍要把它与独立的 MySQL 关键词召回合并。

Qdrant 内部混合检索默认关闭。原有 Collection 只保存单路稠密向量,而该模式需要命名的稠密向量和稀疏向量;即使修改了 Collection 的向量 Schema,已有 Point 也不会自动生成缺失的 BM25 稀疏向量,仍然需要重新计算并回填。为了避免新旧 Point 结构混杂,并保留快速回滚能力,本文采用更稳妥的迁移方式:新建 Collection,重建全部索引,校验完成后再切换。

版本补充:Qdrant 1.18.0 开始支持为已有 Collection 动态增加或删除命名向量配置,但它解决的是 Schema 变更,不会替旧数据生成向量。因此,“可以修改结构”和“不需要重建数据”是两回事;如果使用更早版本,或者希望迁移过程容易验证和回滚,新建 Collection 仍然是更简单的方案。

3. RRF 融合的是名次,不是原始分数

MySQL 全文分数、Qdrant 稠密向量相似度,以及 Qdrant 内部混合检索的融合分数不在同一尺度上,直接相加没有明确含义。

服务层因此使用 RRF(Reciprocal Rank Fusion,倒数排名融合)。通俗地说,可以把它理解成“让多个排行榜共同投票”:一个结果排名越靠前,这一票的权重越高;如果它同时出现在多个排行榜中,各路贡献还会累加,最终通常比只被一路检索看中的结果更靠前。

例如,关键词检索给出的前三名是 A、B、C,向量检索给出的前三名是 C、A、D。A 和 C 都获得了两路检索的认可,而 B 和 D 只在一路出现,因此融合后 A、C 通常更有优势。RRF 不需要判断关键词检索的 8 分是否等于向量检索的 0.8 分,只需要比较它们各自在本路结果中的名次。

这个名字可以拆开理解:

  • Rank:只看一个切片在每路结果中排第几名;
  • Reciprocal:把名次转换成倒数分数,排名越靠前,分母越小,得到的分数越高;
  • Fusion:同一个切片如果出现在多路结果中,就把它在各路得到的分数相加。

它不比较原始分数,只看同一切片在每路结果中的排名:

1
score += 1 / (rrf_k + rank + 1)

代码中的 rank 从 0 开始,因此最后要加 1。如果按日常习惯把第一名记为 1、第二名记为 2,公式可以更直观地写成:

1
2
本路贡献分数 = 1 / (rrf_k + 名次)
最终 RRF 分数 = 一个切片在所有召回路径中的贡献分数之和

假设 rrf_k = 60,有两个切片:

切片 MySQL 关键词排名 Qdrant 排名 RRF 分数(约)
A 第 2 名 第 1 名 1/62 + 1/61 = 0.0325
B 第 1 名 未命中 1/61 = 0.0164

虽然 B 在 MySQL 中排第一,但 A 同时被两条路径排在前面,所以融合后 A 的分数更高。RRF 想表达的正是:被多种检索方法共同认可的结果,通常比只在单一路径中排名靠前的结果更可靠。

rrf_k 是平滑常数。它越大,排名衰减曲线越平缓,相邻名次之间的贡献差异越小;它越小,靠前名次的优势越明显。当前配置取 60。

融合后按 chunk_id 去重,可选地过滤低于阈值的结果,再排序并保留前 20 条。

当前配置如下:

1
2
3
4
5
6
7
trial_cue_search:
keyword_candidate_limit: 40
vector_candidate_limit: 40
fusion_candidate_limit: 20
result_limit: 8
rrf_k: 60
min_fusion_score: 0

这些数字只是工程默认值,不是经过证明的最佳参数。真正调整它们,需要固定评测集,而不是凭线上感觉反复试。

4. Reranker 只做最后一轮语义精排

RRF 和 Reranker 都会影响排序,但解决的问题不同:

  • RRF 负责融合多路排名。 它根据切片在关键词和向量结果中的名次计算分数,不重新阅读切片内容;
  • Reranker 负责判断语义相关性。 它同时读取“问题 + 候选片段”,重新评估每个片段与问题的语义相关程度。

例如用户问“款项最晚什么时候支付”。一个片段可能多次出现“款项”,因而在关键词检索中排名靠前,却没有说明付款期限;另一个片段只出现一次相关表述,却明确写着“剩余款项应在验收完成后 10 日内支付”。Reranker 会结合完整问题和片段语义,把后者排到前面。

在当前流程中,RRF 先把关键词与向量结果融合成最多 20 条候选,Reranker 再对这些候选打分,选出最终 8 条交给 LLM。它只负责筛减候选并重新排序,不直接生成答案。

这里的“可选”表示 Reranker 不是问答链路的硬依赖:

  • 已配置并启用时,执行语义精排;
  • 没有配置时,直接使用 RRF 的排序结果;
  • 调用失败或响应非法时,降级使用 RRF 的排序结果,整次问答不会因此失败。

当前 Reranker 通过 HTTP 服务调用,返回候选下标和相关性分数。后端还会检查:

  • 下标不能越界或重复;
  • 分数不能是 NaN 或无穷;
  • 返回数量不能超过请求的 top_k

一句话概括:RRF 负责融合多路检索排名,Reranker 负责进一步判断哪些片段与问题更相关。

5. LLM 只看到最终候选

经过前面的过滤和排序,最终只有少量切片会进入 LLM 上下文。每条切片包含:

1
2
3
4
chunk_id
文件名
起止页码
正文

Prompt 要求模型只能根据这些材料回答;材料不足时必须明确提示,并返回结构化 JSON:

1
2
3
4
5
6
7
{
"suggested_reply": "剩余款项应在验收完成后 10 日内支付。",
"basis_chunk_ids": [12],
"risks": "需要确认验收完成日期。",
"confidence": "high",
"risk_level": "medium"
}

其中,confidencerisk_level 的允许值都是 lowmediumhighconfidence 表示现有材料对回答内容的支持程度;risk_level 表示直接采用该回答可能产生的业务风险,或回答中是否仍缺少关键前提。两者不是相反关系:示例里的答案有直接原文依据,因此置信度为高;但要计算具体截止日期,还缺少“验收完成日期”,所以风险等级仍为中。

没有任何检索结果时,服务不会调用 LLM,而是直接返回“材料依据不足”。LLM 未配置、调用失败或输出无效时,也只返回降级提示,不伪造一条看似完整的回答。

6. 模型只选择引用编号,引用详情由后端组装

basis_chunk_ids 只是模型声称使用了哪些片段,不能直接视为可信引用。

后端会把本次最终检索结果建成白名单,然后:

  1. 只接受白名单中的 chunk_id
  2. 删除重复编号;
  3. 从后端持有的切片重新组装文件名、文档 ID、页码和原文;
  4. 将单条引用限制在 500 个 rune;
  5. 同时返回文件 SHA-256 和切片内容 SHA-256,用于识别材料内容,并判断文件或切片是否发生变化。

如果没有任何编号通过校验,接口会强制返回:

1
2
3
4
basis = []
basis_verified = false
confidence = low
risk_level 至少为 medium

系统不会为了让页面看起来完整,就自动拿第一条检索结果冒充模型依据。

不过这里要区分两个概念:

  • basis_verified=true 表示引用编号确实来自本次检索白名单;
  • 不表示这些引用已经充分支持回答中的每一个事实。

当前实现解决的是“引用不能凭空伪造”,还没有完全解决“回答与引用是否逐项一致”。

故障时,哪些可以降级,哪些必须终止

向量检索和精排都是增强能力,而不是问答接口的硬依赖。降级结果取决于失败发生在哪一层:

  • Embedding 与 Qdrant 没有同时启用、问题向量生成失败、Qdrant 查询失败,或者向量命中无法从 MySQL 安全回表时,退回 MySQL 关键词检索;
  • Reranker 调用失败或返回非法结果时,保留 RRF 融合后的排序结果。

context.Canceledcontext.DeadlineExceeded 不应被包装成一次“成功降级”。调用方已经取消或上游截止时间已经耗尽时,整条链路应该尽快终止。

两类情况虽然都表现为错误,但处理语义不同:

  • 可选依赖失败:保留已有能力,继续降级执行;
  • 请求生命周期结束:停止所有工作,把取消或超时继续向上传递。

记录检索过程,才能知道回答是怎样产生的

每次问答除了保存问题、回答、风险和引用,当前还会记录:

1
2
3
4
5
retrieval_mode        keyword 或 hybrid
embedding_model 查询使用的 Embedding 模型
keyword_hit_count 关键词候选数
vector_hit_count Qdrant 候选数
retrieval_latency_ms 检索耗时

这里的 retrieval_mode=hybrid服务层同时使用了 MySQL 和 Qdrant 两路候选;它不等价于“Qdrant 内部一定启用了 Dense + Sparse/BM25”。如果两层模式都需要观测,应拆成独立字段,例如 retrieval_modeqdrant_query_mode,避免一个 hybrid 同时表达两件事。

现有字段已经能回答:请求是否退化成关键词检索、向量路径是否返回候选、总检索耗时是否异常,以及查询使用了哪个 Embedding 模型。但要定位一次慢请求或复现一次回答,还需要更细的字段:

1
2
3
4
5
6
7
8
9
10
11
12
degraded                 是否发生降级
degradation_reason 降级原因
keyword_latency_ms MySQL 关键词召回耗时
embedding_latency_ms 问题向量生成耗时
vector_latency_ms Qdrant 查询耗时
rerank_latency_ms 精排耗时
llm_latency_ms 回答生成耗时
embedding_model_version Embedding 模型版本
reranker_model_version Reranker 模型版本
prompt_version Prompt 版本
index_generation 索引代次
qdrant_query_mode dense 或 dense_sparse

这些字段属于下一步的可观测性目标,不应写成已经落地的能力。它们可以把“这次回答不好”进一步拆解为召回、融合、精排、模型或降级问题,也能支持后续对比不同切片参数、RRF 阈值与模型版本。

回头看,真正重要的是七个系统取舍

取舍 当前选择 主要目的
上传与处理 HTTP 接收与后台任务解耦 避免长请求,并支持重试与恢复
正文与向量状态 分开记录 向量服务故障时仍保留关键词检索
多格式解析 先统一成“页码 + 文本” 使后续切片和索引逻辑与原始格式解耦
权限边界 贯穿每路召回,并在 MySQL 回表校验 避免由外部向量索引直接决定数据访问权限
检索策略 先扩大召回,再融合和精排 尽量减少漏召回,同时控制精排成本和模型上下文长度
引用生成 模型只选 ID,后端组装原文 防止模型伪造文件名、页码和内容
页码与召回率 当前切片不跨页 优先保证引用可核对

这些选择共同指向一个目标:外部服务失败或模型输出不可靠时,系统仍尽量保留已有能力;同时记录回答使用了哪些材料,并逐步补齐降级原因和处理阶段的可观测性。

目前还没有解决好的问题

这套代码已经形成了完整链路,但“能够运行”距离“回答可靠”仍有明显差距。

1. 缺少可重复的评测集

需要准备一批脱敏的“问题—正确文档—正确页码—正确切片”样本,至少统计:

  • Recall@3、Recall@5、Recall@8;
  • MRR 或 nDCG;
  • 引用正确率和引用完整率;
  • 无答案问题的拒答率;
  • 关键词、向量、RRF、Reranker 各阶段耗时;
  • 降级率和无有效引用率。

没有这些数据,40 / 40 / 20 / 8rrf_k=60min_fusion_score=0 都只能算初始配置。

2. 引用白名单不等于事实一致性

下一步需要把回答拆成独立的事实陈述(claim),逐项检查每条陈述是否得到引用支持,并区分:

  • 材料直接记载的事实;
  • 模型根据多段材料做出的归纳;
  • 没有依据、需要人工确认的内容。

3. 文档解析仍有未覆盖的边界情况

  • 混合型 PDF 应逐页决定是否 OCR;
  • 应保存 OCR 置信度和文字坐标,以支持原页高亮;
  • DOCX 需要限制解压大小、压缩比和 XML 复杂度;
  • 文件类型检查应结合 magic number,而不只看扩展名。

4. 任务幂等与多存储一致性还不够稳

MySQL、对象存储和 Qdrant 不共享一个数据库事务。除了前文提到的任务租约、fencing token、确定性 Point ID 和索引 generation,还需要周期性对账:修复 MySQL 中存在但 Qdrant 缺失的切片,清理 Qdrant 中已经失去业务记录的孤立 Point。

删除链路也有同样的问题。当前删除顺序是先清理 Qdrant,再删除原文件,最后删除 MySQL 记录。任一步中途失败,都可能留下残余资源或不一致状态。删除过程仍需要任务化、幂等化,并允许补偿重试。

5. Qdrant 过滤字段需要索引规划

用户、资料库、文档、页码和材料类型等条件被写入 Payload,并不意味着过滤一定高效。数据量增大后,应为高频过滤字段建立 Qdrant Payload Index,并结合字段基数和查询模式验证效果。本文没有确认当前部署已经为这些字段建索引,因此将其列为生产化改进,而不是既有能力。

6. 模型输入输出边界还需加强

文档内容本身也是不可信输入。后续还需要处理 Prompt Injection、严格 JSON Schema、字段长度限制、敏感信息脱敏和模型供应商的数据留存策略。

参考资料

代码位置索引

本文所称的「知页」,在代码中使用的内部模块名为 trial_cue,因此部分文件名和配置项仍保留此前缀。

如果要对照代码阅读,主要模块如下:

职责 主要代码位置
文档上传、状态查询、重试和删除 internal/service/trial_cue_document.go
解析 PDF、DOCX、文本和图片 pkg/document/
OCR 及 PDF 转图片回退 pkg/ocr/
切片 pkg/document/trial_cue_chunker.go
后台任务调度 internal/task/trial_cue_runner.go
MySQL 关键词检索 internal/dao/trial_cue_search.go
Embedding pkg/embedding/
Qdrant 索引和检索 pkg/vectorstore/
RRF、过滤、降级和回答编排 internal/service/trial_cue_question.go
可选 Reranker pkg/reranker/
LLM 上下文与结构化输出 pkg/llm/trial_cue.go

总结

这套文档问答链路的核心不是“把文档转成向量”,而是把原始文件逐步变成可检索、可过滤、可追踪、可核对的材料,再约束模型只基于这些材料回答。

真正需要守住的边界有三条:解析结果是否完整,每一条检索路径是否始终受权限约束,最终引用是否确实来自本次候选。Embedding、Qdrant 内部混合检索和 Reranker 都可以增强效果,但任务幂等、降级语义、引用校验和离线评测,才决定这个系统能否稳定地从 Demo 走向实际使用。