一个文档问答项目里的 RAG 实现

最近在做一个个人 Go 项目,其中的文档问答功能用到了 RAG、Embedding 和向量检索。这篇文章就以这套功能为例,顺着文件上传、解析、切片、检索和回答生成的代码,看看这些概念在项目里分别对应什么。为了方便行文,下文把这个项目称为「知页」。

在「知页」里,用户可以新建资料库并上传 PDF、Word 或图片。后台处理完成后,用户就可以针对这些文档提问。回答除了正文,还会带上文件名、页码和原文片段,方便回到文档核对。

先把流程拆开

如果文档很短,最省事的办法当然是把全文和问题一起发给模型。但资料库里一旦有多份长文档,这种做法很快就会遇到问题:

  • 全文可能超过模型的上下文限制;
  • 每次重复发送,耗时和调用成本都会增加;
  • 扫描 PDF 和图片本身没有可检索的文本;
  • 回答即使看起来合理,也很难定位到原文件和页码。

所以代码里没有直接做“上传文件后问模型”,而是分成了两条流程:

1
2
文档处理:文件 → 解析 / OCR → 切片 → 关键词索引 / 向量索引
在线问答:问题 → 混合检索 → 组装上下文 → 模型回答 → 引用校验

文档只需要处理一次,后面的每次提问都复用已经生成的文本和索引。

项目里的几个主要部分

知页文档问答服务架构

后端使用 Go 和 Gin。MySQL 保存资料库、文档、切片、后台任务和问答记录;原文件先放在本地目录;向量写入 Qdrant;PDF 文本提取依赖 Poppler,扫描件和图片交给 OCR;检索完成后再调用大模型。

这里有一个我一开始容易忽略的点:文本处理状态和向量状态要分开。

一份文档可能已经解析成功,但 Embedding 服务暂时不可用。如果只保留一个“处理成功/失败”状态,向量服务出错就会让整份文档不可用。现在的做法是分别记录正文处理和向量化状态。正文完成后,关键词检索就可以工作;向量失败时,单独重试向量任务即可,不需要重新解析或 OCR。

上传接口只做接收,不做解析

上传接口主要完成下面几件事:

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

检查文件类型和大小

保存原文件并计算 SHA-256

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

创建文档记录和解析任务

文件名会先做清理,真正落盘时使用 UUID,目录按用户和资料库隔离。读取上传内容时还会通过 io.LimitReader 限制实际字节数,不能只相信请求中声明的文件大小。

查重也不只做一次查询。应用层先按 SHA-256 查找已有文档,数据库里再用唯一约束处理并发上传。如果两个相同文件同时写入,最终只保留一条有效记录,本次上传产生的临时文件会被清理。

接口在创建解析任务后就返回,不等待 PDF 解析。普通文本可能几乎立刻完成,扫描 PDF 却要逐页 OCR,如果都放在 HTTP 请求里,超时、重试和进度展示都会变得麻烦。

解析和向量化放到后台任务里

后台 Runner 定时领取任务,并限制同时执行的任务数量。每个任务都有超时、进度和心跳;服务异常退出后,长时间没有心跳的任务会被重新领取。任务执行中的 panic 也会被捕获并记为失败,避免状态一直停在 processing

文档处理分成两个任务:

1
2
3
4
5
document_parse
读取文件 → 解析 / OCR → 切片 → 写入 MySQL

document_embed
分批计算 Embedding → 写入 Qdrant

拆开之后,解析成功但向量化失败不会互相影响。以后更换 Embedding 模型时,也只需要重建向量,不必再读一遍原文件。

任务使用带截止时间的 context.Context。OCR、Embedding、Qdrant 请求和 Poppler 进程都会接收这个上下文,任务超时或服务停止时,下游调用也能跟着结束。

不同格式先统一成带页码的文本

目前处理的文件类型包括:

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

解析层最后都返回同一种结构:

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

普通文本按一页处理。DOCX 本质上是 ZIP,代码读取 word/document.xml 中的文本节点,并在段落结束的位置补换行。

PDF 先用 Poppler 按页读取文本层。如果整份文档几乎没有有效文字,再转到 OCR;图片则直接进入 OCR。没有配置 OCR 时,文档会进入 ocr_required 状态,而不是笼统地显示处理失败。当前 PDF OCR 默认最多处理 20 页,防止单个文件消耗太多外部调用。

从这一层往后,切片和检索不再关心输入是 PDF、Word 还是图片,只处理“页码 + 文本”。

切片时保留页码

当前切片长度是 800 个 rune,相邻切片重叠 100 个 rune。切片不会跨页,并优先在换行、句号、问号、分号或空白处断开。

每个切片保存这些信息:

1
2
3
4
page_start / page_end
chunk_index
content
content_hash

这里按 rune 而不是字节计数,主要是为了避免把中文 UTF-8 字符截断。切片之间保留少量重叠,是为了减少句子刚好落在边界上造成的信息丢失。

不跨页并不是召回效果最好的切法,但页码会更稳定。这个项目需要把回答定位回原文,所以我暂时把可核对性放在跨页上下文之前。

切片写入 MySQL 后,关键词检索已经可以使用。Embedding 启用时,后台再为切片生成向量。Qdrant 的 Payload 里带有用户、资料库、文档和切片标识,查询时先按用户和资料库过滤。

检索分为关键词和向量两路

提问时,服务先检查当前用户是否有权访问资料库,然后分别走关键词和向量两条检索路径。

关键词检索

MySQL 优先使用带 ngram 分词器的全文索引:

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

查询范围只包含当前用户、当前资料库中已经处理完成的文档。

全文检索报错或没有命中时,还有一层简单回退:英文和数字按连续词拆分,中文生成 2~4 字片段,再用 LIKE 找候选并在代码里计分。这条路径适合兜底,也比较容易命中文件中的名称、编号和日期,但数据量大以后不会有太好的性能。

向量检索

问题会先转成 Embedding,再到 Qdrant 中查找语义相近的切片。它能补充关键词检索不容易覆盖的近义表达和概括性问题。

向量检索不是硬依赖。Embedding 或 Qdrant 没有启用、调用失败,或者向量结果无法从 MySQL 回查时,接口都会退回关键词结果,并记录降级原因。

用 RRF 合并

关键词和向量路径各取最多 30 条结果,再按 RRF 合并:

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

RRF 只看一条结果在两组列表中的名次,不直接比较全文检索分数和向量相似度。两边都排得靠前的切片,合并后通常也会靠前。

去重后取前 8 个切片进入回答阶段。30、60、8 都只是当前代码里的参数,不代表它们一定合适,后面还是要用真实问题做评测。

回答里的引用不能完全交给模型

发送给模型的不是资料库全文,而是检索得到的几个切片。每个切片都带有:

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

模型返回结构化 JSON:

1
2
3
4
5
6
7
{
"suggested_reply": "回答内容",
"basis_chunk_ids": [12, 36],
"risks": "需要核实的内容",
"confidence": "low|medium|high",
"risk_level": "low|medium|high"
}

其中 basis_chunk_ids 只是模型选择的切片编号。最终展示的文件名、页码和引用原文由后端重新组装,不能直接采用模型返回的内容。

具体做法是把本次检索结果当作白名单:只有出现在白名单中的 chunk_id 才会被接受,重复编号会被去掉,引用内容再从数据库中读取,单条最多返回 500 个 rune。

如果一个编号都没有通过校验,接口会返回:

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

系统不会为了让页面看起来完整,就自动拿第一条检索结果冒充引用。LLM 没有启用或生成失败时,也只返回检索降级提示,不伪造回答依据。

不过,这种校验只能说明“引用来自本次检索结果”,不能说明它真的支持回答里的每一句话。引用是否充分,仍然是当前实现没有解决好的问题。

目前还欠缺什么

这版代码已经可以走通上传、处理、检索和回答,但还有几处比较明显的缺口:

  • 缺少最低相关性判断。 即使检索结果很弱,模型仍可能继续生成回答;目前也没有无答案分类和回答—引用一致性检查。
  • 关键词回退只适合小数据量。 LIKE 最多扫描 200 个候选,前导 % 也很难利用普通索引。
  • 删除过程仍是同步串行。 现在依次删除 Qdrant 向量、原文件和数据库记录,中间失败可能留下不一致状态。
  • 文件解析还有资源边界。 混合型 PDF 可能同时有文本页和扫描页,目前按整份文档判断是否 OCR;DOCX 也还需要补解压大小和压缩比限制。
  • 模型给出的置信度没有校准。 confidencerisk_level 适合做界面提示,不能拿来直接做自动决策。

下一步我会先准备一批“问题—正确引用”样本,看看正确片段能不能进入 Top 3、Top 5 和 Top 8,同时记录无答案识别、引用正确率和检索耗时。有评测结果后,再决定是调整切片、增加 Reranker,还是更换关键词检索方案。

最后

做之前,我比较关注 Embedding 和向量数据库;真正写完一遍后,反而觉得向量检索只是中间的一环。

前面要处理文件格式、OCR、任务状态和切片,后面还要限制模型能看到什么、检查引用能不能回到原文。任何一段出了问题,最后的回答都会受影响。

现在我对这条流程的理解可以简单概括为:先把不同格式的文件整理成带位置的文本,再找出和问题有关的片段,最后让模型基于这些片段回答,并保留回到原文核对的入口。