RAG 文档处理流水线:从上传文件到可检索 chunk
RAG 文档处理流水线:从上传文件到可检索 chunk
系列定位:这是
my-rag的 MVP 实战记录,重点是把真实链路讲清楚,而不是提前包装成完整企业平台。
阅读时间:10 分钟
适合读者:正在做文档解析、chunk 切分、embedding 成本控制的开发者
关键词:文档解析、chunk、embedding、费用预估、状态流转
为什么文档处理比聊天框更重要
很多人做 RAG,第一眼会盯着 Chat UI:用户输入问题,模型生成回答,看起来很智能。
但真正决定回答质量的,往往不是聊天框,而是聊天框背后的文档处理流水线。只要前面一步做错,后面就会连锁出问题:
- 解析阶段丢掉标题,回答就少了上下文。
- chunk 切得太碎,召回片段就不完整。
- chunk 切得太大,embedding 成本和上下文长度都会上升。
- 半成品文档进入检索,回答就可能引用错误资料。
- 向量化失败但状态没更新,用户会误以为系统不可用。
所以在 my-rag 里,我把文档处理拆成一条明确的流水线:上传 -> 解析 -> 切块 -> 费用预估 -> embedding -> READY。
这篇文章就讲这条流水线为什么这样设计。
一、文档状态是整条链路的骨架
文档处理不是一个瞬间完成的动作,而是一组阶段性任务。每个阶段都应该有明确状态。
这些状态解决了三个问题。
第一,前端知道该显示什么。比如 CHUNKED 表示可以展示 embedding 费用确认按钮,READY 才能进入 Chat 检索。
第二,后端知道哪些文档能参与检索。DocumentScopeResolver 在解析检索范围时,只返回 READY 状态的文档,避免把未完成索引的资料混进回答。
第三,排查问题有依据。文档卡在 PARSING、CHUNKED 还是 EMBEDDING,代表完全不同的故障方向。
二、上传阶段:元数据比文件本身更先被管理
上传文件时,系统不仅保存文件,还会记录文档元数据:
- 标题。
- 文件名。
- 文件类型。
- 文件大小。
- 文件 hash。
- 本地存储路径。
- 当前状态。
- 错误信息。
其中 file_hash 很重要。它可以帮助系统识别重复文件,避免用户反复上传同一份资料造成重复索引。
第一版没有直接接对象存储,而是先使用本地文件系统。这个选择不复杂,但对 MVP 来说够用。真正需要多实例部署、统一备份和跨机器访问时,再把存储层替换成 MinIO 或云对象存储也不迟。
三、解析阶段:先把“格式差异”收敛掉
文档格式会带来很多差异:TXT 是纯文本,Markdown 有标题结构,PDF、EPUB、DOCX 可能需要专门解析库。
my-rag 的解析层使用 DocumentParser 抽象,把不同格式收敛成统一的 ParsedDocument。上层不用关心具体文件怎么读,只关心最终得到的结构化文本。
核心思路是:
private DocumentParser selectParser(RagDocument document) {
String fileType = document.getFileType();
if (!StringUtils.hasText(fileType)) {
throw new IllegalStateException("Document file type is empty: " + document.getId());
}
return parsers.stream()
.filter(parser -> parser.supports(fileType))
.findFirst()
.orElseThrow(() -> new IllegalStateException("Unsupported parser file type: " + fileType));
}
这段代码不复杂,但它带来一个好处:新增格式时,不需要改文档处理主流程,只要新增一个 parser 实现。
更重要的是,解析失败必须落到状态机里。如果解析结果为空,系统不能继续切块,而应该把文档标记为 FAILED,并记录错误原因。
四、切块阶段:不是按字数切开那么简单
RAG 里的 chunk 不是普通分页。它既要适合 embedding,又要保留足够语义。
my-rag 的 chunk 逻辑围绕几个原则:
- 优先按章节和段落组织内容。
- 每个 chunk 有最小和最大字符数限制。
- 对超长段落做强制切分。
- 相邻 chunk 保留一定 overlap。
- 每个 chunk 记录章节标题、段落范围和内容 hash。
这些信息后面都会用上。
章节标题能帮助模型理解片段所在位置;段落范围能帮助调试;内容 hash 可以避免重复 chunk;token 估算可以用于成本预估。
4.1 overlap 的价值
很多长文档的问题都发生在边界上。
比如一个定义在上一段,解释在下一段。如果 chunk 正好从中间切开,召回到任何一边都会缺少上下文。
overlap 的作用就是让边界附近的信息重复一点点。它会增加少量存储和 embedding 成本,但能减少“召回片段看起来相关,却回答不完整”的情况。
4.2 chunk 大小没有万能值
chunk 太小,检索更精细,但上下文容易碎。chunk 太大,语义更完整,但召回排序变粗,embedding 成本也更高。
所以第一版没有把 chunk 策略做成复杂算法,而是先做可配置的字符范围和 overlap。等有足够真实问答日志后,再基于失败案例调参。
这是 RAG 很重要的一条经验:不要在没有评测样本时过度优化 chunk 算法。
五、关键词索引:给精确问题留一条路
向量检索擅长语义相似,但对精确符号、配置项、章节名不一定稳定。
比如用户问:
RAG_EMBEDDING_BATCH_SIZE 怎么配置?
这种问题里,环境变量名就是强信号。向量检索可能能理解“配置 embedding 批大小”,但关键词检索更直接。
因此 my-rag 在 chunk 写入时,可以同步维护一份关键词检索索引:
search_text保存章节标题和正文。search_vector保存 PostgreSQL 全文搜索向量。- 是否启用由配置控制。
第一版并不把关键词检索包装成万能能力。它只是给精确匹配问题补一条召回通道。
六、费用预估:把 embedding 从“自动动作”改成“用户确认”
embedding 是 RAG 系统里最容易被忽略的成本点。
如果用户上传一本很长的电子书,系统自动把几百个 chunk 全部送去 embedding API,用户既不知道会花多少钱,也无法在索引前做判断。
所以 my-rag 把文档索引拆成两步:
- 先解析并切块,得到 chunk 数和本地 token 估算。
- 用户确认预估成本后,再生成 embedding。
预估公式很直接:
estimatedCost = estimatedTokens / 1000 * pricePer1kTokens
这里的 estimatedTokens 来自本地估算,不可能和服务商账单完全一致。但它足够用于交互决策:这次索引大概是一点点成本,还是一次明显的大批量调用。
七、embedding 阶段:先保证正确,再考虑吞吐
生成 embedding 时,系统会读取文档下的全部 chunk,然后逐个调用 embedding client,把向量写入 rag_chunk_embedding。
第一版实现偏保守:它没有为了吞吐一上来就做复杂并发,而是先保证几个关键点:
- embedding 模型名不能为空。
- 返回向量数量必须和输入数量一致。
- 向量维度必须符合配置。
- 向量里不能出现
null或非有限数值。 - 每次 API 调用都写入日志,方便排查失败。
这些校验看起来啰嗦,但非常值得。因为向量一旦写错,后面检索表现会变得很难解释。
比如维度不一致是显性错误;但如果服务返回了空向量、错位向量,系统没有检查就写库,后面只会表现为“怎么搜都不准”。
八、READY 不是结束,而是可检索的起点
文档进入 READY 后,才真正可以参与问答。
这时系统至少拥有三类数据:
- 文档元数据。
- chunk 内容和段落范围。
- chunk 对应的 embedding。
如果开启了关键词索引,还会多一份全文搜索索引。
这些数据共同支撑后续检索。用户提问时,系统不会直接把整本文档塞给大模型,而是先根据问题召回相关 chunk,再构建上下文。
九、这条流水线目前还没解决什么
为了避免把 MVP 写成“全能系统”,这条流水线当前还有几个需要继续演进的点:文档处理天然适合队列化,后续文档变大、用户变多时,可以把 parse、chunk、embedding 拆成后台任务,并加入重试、暂停、恢复和进度推送;token 估算目前主要用于成本预估,不等于模型服务的真实计费 token,后续可以按具体模型接入 tokenizer;文件格式支持也可以继续扩展,但第一版重点不是“支持所有格式”,而是把 parser 抽象做好,让 PDF、DOCX、HTML、更多 EPUB 结构接入时不需要大改主流程;chunk 策略也不是一次设计完成的,真正有效的优化方式,是拿 Chat log 里的失败样本反推:到底是 chunk 太碎、召回不准、上下文太短,还是模型没有按资料回答。
十、我从这条流水线里得到的经验
做 RAG 文档处理时,我觉得有几条经验很值得提前放进设计里:
- 状态要清楚:不要让半成品文档参与检索。
- 成本要透明:embedding 之前给用户一个确认机会。
- chunk 要可追踪:记录章节、段落范围和 hash,方便调试。
- 失败要可解释:解析失败、空文档、向量维度错误都要有明确错误。
- 先简单再扩展:队列、缓存、对象存储都可以后加,但主流程先要跑稳。
结语:RAG 的质量从文件进入系统那一刻就开始了
一个 RAG 系统回答得好不好,不是从用户按下“发送”才开始决定的。更早的时候,答案质量就已经被文档解析、chunk 切分、embedding 校验和状态管理影响了。
my-rag 的文档流水线还不复杂,但它已经把最关键的闭环打通:文件上传后,系统知道它处在哪个阶段,知道它能不能被检索,也知道它的向量化成本大概是多少。
下一篇我会继续讲检索和问答:当文档已经 READY,系统如何把向量检索、关键词召回、RRF 排序和来源引用串起来,让回答更可信。
下一篇:《混合检索与 RAG 回答可信度:从召回 chunk 到带来源回答》