白话讲解

知识库文档上传后的异步解析和切块策略推荐链路。用户上传文件后,系统不会同步完成解析,而是先做校验、把原始文件上传到 MinIO,然后创建 document、task、task_log 三条记录。这里用 TransactionTemplate 控制事务边界,事务提交成功之后再发 Kafka 消息,避免消费端查不到上下文

Kafka Consumer 收到消息后只做反序列化和转发,真正的业务在 handleParseRoute 里。它会查数据库上下文、更新任务状态、从 MinIO 下载文件、用 Tika 提取文本、做清洗、做结构树提取和统计打分。

结构树提取这块做成了四阶段流水线:信号提取、歧义消解、层级构建、树校验,能把扁平文本识别成带父子关系、深度、路径、兄弟关系的结构节点,后面用于目录导航、Neo4j 图谱、结构化切块和章节级 RAG 检索。

最后把标题数、段落数、字符数、token 估算、结构等级和内容质量等级打包成 DocumentAnalysisResult,基于这些指标推荐 Parent / Child 双层切块策略——Child 小块负责精准召回,Parent 大块负责回答上下文。推荐方案先落库等待用户确认,确认后再进入真正的切块和索引构建。

这个模块做的事情,说白了就一件:用户丢一个文档进来,要把它变成一个能被 RAG 系统"问得动"的东西。

用户传进来的可能是 PDF、Word、Markdown,但模型不可能直接读原始文件去回答问题。中间得经过解析、清洗、识别结构、切块、向量化、写进 ES、写进向量库、写进 Neo4j 一整条流水线。

这个模块负责的是这条流水线最前面的那截——从"原始上传文件"到"一份可以进入索引构建的结构化文档资产"。再往后的向量化、写库那些是别的环节,但他们能不能干好,取决于这里出的东西干不干净、结构清不清楚。

一句话概括价值:给整个知识库打地基。地基歪了,后面 RAG 答得再花哨也是虚的。

整个流程可以想成两段:一段是"上传接入",一段是"异步处理"。

上传接入这一段非常轻。 用户调上传接口,做四件事:校验文件、把原始文件丢进 MinIO、在 MySQL 里写三张表(document 文档表、task 任务表、task_log 日志表)、最后发一条 Kafka 消息出去。接口到这就返回了,用户立刻能看到"文档已收到,正在处理中"。

异步处理这一段才是真正的活。 Kafka 消费者收到消息后,进到一个叫 handleParseRoute 的主流程,它会:从 MinIO 把文件下载回来 → 用 Tika 把它转成纯文本 → 做文本清洗 → 提取文档结构树 → 做统计打分 → 打包成一个叫 DocumentAnalysisResult 的标准结果 → 最后基于这个结果推荐切块策略,落库等用户确认。

所以可以理解成:上传接口负责"接客",Kafka 后面的异步链路负责"做菜"。接客要快、要稳、要可追踪;做菜可以慢,但要做得精细。

那为什么不直接在上传接口里同步把活干完?因为后面这些事都很重——PDF 可能几百兆,Word 可能有复杂格式,还要做结构树提取、图谱同步、策略推荐。同步执行会让接口非常慢,体验差,而且中间一旦失败整条链路很难恢复。改成异步之后,接口响应快、解析服务可以横向扩展、失败之后能根据任务表补偿重试、前端还能拿 documentId 和 taskId 查处理进度,一举多得。

事务和 Kafka 的先后顺序

上传接口里要写三张表:document、task、task_log。然后要发 Kafka 消息通知后面去处理。

问题来了:这条 Kafka 消息,到底应该在数据库事务里发,还是事务提交之后再发?

第一反应是放在事务里,看起来"原子"。但这是个坑。因为 Kafka 不是数据库,它不参与本地事务。如果在事务还没 commit 的时候就把消息发出去了,消费者那边手速一快,拿着 documentId 去查数据库——查不到。因为这边还没提交,对它不可见。

所以这里用的是 TransactionTemplate 手动控制事务边界:事务先 commit,commit 成功之后再发 Kafka 消息。

当然这样也不是完美的——理论上事务提交完、Kafka 还没发出去这中间如果服务挂了,消息就丢了。但这种情况有补偿机制兜底:定时任务扫 task 表里那些状态长期停在"待处理"的记录,重新投递。所以最终一致性是有保障的

另外,Kafka 消息体里只放 documentId 和 taskId 两个字段,不放完整文档信息。原因有两个:一是消息体小,传输快;二是消息里的数据可能和数据库里的最新状态不一致,真实状态以数据库为准。消费端拿到 ID 之后回查数据库就行。

三张表分工:让整条链路可追踪、可恢复、可排查

一开始也想过只建一张表搞定。后来发现不行,因为**"这份文档现在怎么样了"和"这次处理动作进行到哪了"是两个不同的问题**。

所以拆成了三张表,职责非常清楚:

  • document 表管"资产状态"——这份文档叫什么、存哪了、解析到哪步了、索引建好没。它代表这份文档作为一个资产的状态。
  • task 表管"动作状态"——当前这次异步处理任务跑到哪个阶段了,是 CONTENT_PARSE 还是 STRATEGY_ROUTE。它代表一次执行的状态。
  • task_log 表管"过程证据"——什么时候开始、什么时候结束、失败了报什么错。它是事后排查的依据。

这样设计之后,前端可以拿 documentId 查"这份文档现在到哪了",运维可以拿 taskId 查"这次任务为什么挂了",两个视角互不干扰。

一份文档可能被重新解析多次,每次都是一个新 task,但 document 还是同一个。这就是为什么不能合成一张表——文档是长期存在的资产,任务是一次性的动作,生命周期不一样。

Kafka Consumer 故意做得很薄

Consumer 收到消息后只做三件事:接收 payload、反序列化成 DocumentParseRouteMessage、调用 asyncProcessService.handleParseRoute(documentId, taskId)

真正的业务逻辑全部放在 handleParseRoute 里。

这样做有两个好处。第一,Consumer 层很稳定,不会因为业务逻辑膨胀导致监听线程难维护。第二,后续要做人工重试、定时补偿、后台管理接口,可以复用同一个 handleParseRoute 方法,不一定非要从 Kafka 进来。

进到 handleParseRoute 之后,第一步是根据 documentId 和 taskId 回查数据库,加载上下文。然后把任务状态推到 RUNNING、阶段推到 CONTENT_PARSE,文档状态推到 PARSING,记一条日志说"开始解析"。接着根据 document 表里的 objectName 从 MinIO 重新下载原始文件。

这里有个容易踩的点:异步消费端不能依赖上传接口里的内存数据,因为上传接口早就结束了。必须通过 MinIO 重新下载文件,整个异步链路才是完整闭环的。

文本解析:Tika 抽文本只是第一步,清洗才是地基

文件下载下来之后,交给 Apache Tika 解析。Tika 的作用是把不同格式的文件——PDF、Word、HTML、Markdown 等——统一抽取成纯文本。

但是 Tika 解析出来的文本不能直接用。里面通常有一堆问题:多余空格、多余换行、页眉页脚、控制字符、PDF 硬换行、乱码字符、空段落……

所以解析后必须做文本清洗。这一步看起来不起眼,但其实是整个知识库质量的地基。如果清洗没做好,后面的标题识别、切块、向量化、关键词检索、模型回答都会受影响。垃圾进,垃圾出,这条铁律在 RAG 链路里特别明显。

结构树提取:这块是整个模块的技术含量所在

文本清洗完了之后,做了一件其他人很容易跳过的事:把扁平的纯文本,提取成一棵带父子关系的文档结构树

为什么要做这个?因为很多人做 RAG 是直接把文本切成几百字一段就丢去向量化。但这样会丢一个非常关键的信息——章节归属。用户问"第三章讲了什么",没有结构树根本答不上来。

但提取结构树没那么简单。真实文档里的标题样式有多少种:Markdown 的 ## 项目背景、中文的"第一章 总则"、数字编号的"1.2.3 配置说明"、中文大纲的"一、项目背景"……更头疼的是有些行天然有歧义,比如"1、项目背景"——它可能是个一级标题,也可能只是个列表项。

所以这里没有用一个大正则去硬切,而是做成了四阶段流水线:

第一阶段:信号提取。 逐行扫描清洗后的文本,把每行先粗分类成标题、疑似标题、列表项、步骤、表格、引用、噪声、普通正文等。这一步的原则是"宁可多标,不要漏标",因为漏标后面就补不回来了,多标后面还能通过歧义消解修正。

第二阶段:歧义消解。 专门处理那些低置信度的信号,比如单级编号、中文大纲这些。结合局部上下文做判断,规则定不了的再扔给 LLM 做二次判断。但 LLM 不是主流程,只是辅助——如果每一行都问 LLM,成本和延迟都扛不住,稳定性也比规则差。所以核心思路是:规则负责大部分确定场景,LLM 只处理边界和歧义

第三阶段:层级构建。 把这些扁平信号组装成父子关系。比如看到 1、1.1、1.1.1、1.2、2 这样的序列,要能正确判断 1.1.1 是 1.1 的儿子,1.2 还是 1 的儿子,2 和 1 是兄弟。实现上结合 section 栈、列表栈、缩进、编号路径等上下文,用线性扫描的方式逐步构建草稿树。

第四阶段:树校验。 最后做质量收口。折叠重复标题、修复数字父链、修复非法父节点、重算 depth、重建路径和兄弟关系。最后输出稳定可落库的 DocumentStructureNodeCandidate

每个结构节点上,会维护两套路径

  • canonicalPath 给机器用——格式稳定,适合做节点定位、唯一标识、URL 拼接、内部检索。
  • sectionPath 给人看——用真实标题拼起来,比如"产品手册 / 第二章 系统设计 / 2.1 架构设计",前端目录展示和 RAG 回答里的引用来源都用它。

这两套路径职责不同,不是重复,而是分工。

这棵结构树后面会同步到 Neo4j,每个节点的 nodeNo、nodeType、parentNodeNo、prevSiblingNodeNo、nextSiblingNodeNo、depth、title、canonicalPath、sectionPath、contentText 这些字段正好映射成图谱里的节点和边。用户问"上一章讲了什么""第三章下面有哪些内容"这种问题时,就可以靠图谱关系去导航,而不是只靠向量相似度硬搜。这是结构化 RAG 比纯向量 RAG 强的地方。


解析阶段的标准输出:DocumentAnalysisResult

拿到结构节点之后,parse() 还会继续做几个统计:标题数量、段落数量、最长段落长度、字符数、token 估算、结构等级、内容质量等级。最后统一打包成 DocumentAnalysisResult

可以把它理解成解析阶段的标准输出。后面的策略推荐不需要关心 Tika 怎么解析、文本怎么清洗、结构树怎么提取,只需要看这个结果对象。

结构等级主要看标题和段落数量:标题数 ≥ 5 判 HIGH,≥ 2 判 MEDIUM,标题很少但段落不少判 LOW,都很少判 UNKNOWN。这个等级直接影响切块策略——结构清晰的文档适合按章节切,结构差的就得靠递归或语义切兜底。

内容质量主要看两个指标:一个是文本长度(特别短可能是解析失败或扫描版 PDF),另一个是乱码比例(出现很多 这种字符说明编码或字体解析有问题)。质量分 LOW / MEDIUM / HIGH,质量差的时候策略推荐就要更谨慎,甚至考虑 LLM 智能切块兜底。

解析结果出来之后,控制流回到 handleParseRoute 做收尾:把清洗后的纯文本上传成 parsed-text.txt 到 MinIO(后续切块、索引、摘要、画像可以直接复用,不用每次重新解析)、把结构节点整体替换落库(避免重新解析时旧结构残留)、同步导航索引、同步 Neo4j 图谱投影、生成文档画像、记录解析完成日志、把任务阶段推进到 STRATEGY_ROUTE


切块策略不写死,是"因文施策"

解析完了之后,没有直接切块,而是先推荐策略,让用户确认了再切。

因为不同文档真的不能用一种切法:只有几百字的 FAQ,按 500 字硬切,问答就被切散了;几十万字的手册不切,超上下文;结构清晰的规范文档,按章节切是最自然的;乱码很多的扫描 PDF,规则切根本没用。

所以会基于 DocumentAnalysisResult 里的指标自动判断哪种策略最合适:

策略 适用条件 一句话理由
结构化切块 文件类型合适 + 结构等级高 顺着章节切,边界最自然
递归切块 全文很长或单段超长 长度兜底,防止单块太大
语义切块 长 + 段落多 + 质量好 按主题边界优化,但成本较高
LLM 切块 质量差 + 配置允许 救火用,不是默认

Parent / Child 双层切块:RAG 里一个反直觉但很关键的设计

这个细节单独拿出来讲,因为它解决了 RAG 里一个根本矛盾检索希望块越小越好,因为块小,向量语义聚焦,召回精准;但回答希望块越大越好,因为块大,模型看到的上下文完整,不容易答半截。

怎么调和?做法是 Parent / Child 双层切块:

  • Child 小块进向量库,负责被检索命中。
  • Parent 大块保留完整上下文,不进向量库
  • 检索时,先在 Child 里召回相关小块,通过 parentId 反查到对应的 Parent 大块,把大块塞给模型回答。

一句话:搜小块,读大块。 既精准又完整。


推荐和执行解耦:策略先落库等确认

策略服务生成的是一个 DocumentStrategyPlanDraft——本身不执行切块,只是方案草稿。

方案会落到数据库里:plan 主记录、parent steps 父块步骤、child steps 子块步骤。状态置为 planStatus = WAIT_CONFIRMstepStatus = WAIT_EXECUTE。用户可以先看到推荐方案,必要时调整。

这种设计的好处是把"推荐"和"执行"分开了:推荐层只负责判断该怎么切,持久化层负责把方案和步骤落库,执行层等用户确认后再真正切块。整个流程更可控,也方便后续失败重试和过程回放。

模块价值总结

这个模块的核心价值有三点。

第一,上传接口设计成异步处理入口,而不是同步重任务接口。上传阶段只做文件接入、状态初始化和 Kafka 投递,耗时的解析和策略推荐放到后台异步执行。

第二,通过 document、task、task_log 三类数据,把文档资产状态、任务执行状态和过程日志拆开管理,保证整个链路可追踪、可恢复、可排查。

第三,在解析阶段,不是简单用 Tika 抽文本,而是在文本清洗之后做结构树提取、统计打分和内容质量评估,再基于这些画像自动推荐 Parent / Child 双层切块策略,为后面的向量检索、ES 检索、Neo4j 图谱和 RAG 问答打基础。

整个设计贯穿几个思想:

思想 体现
轻同步重异步 上传接口只做初始化,重活全给 Kafka 消费方
消息只传 ID Kafka 消息体极简,详细上下文以数据库为准
事务边界精确 TransactionTemplate 手动控制,提交后再发消息
状态机驱动 每个节点都有明确状态,前端可展示进度
规则 + LLM 混合 高确定性用规则,边界歧义才用 LLM,控成本控延迟
多层兜底 每个步骤都有降级策略,不让单点失败打断主链路

用一句话收尾:负责的是知识库 RAG 链路最前面那段地基工程——前面打得稳,后面才能问得准。


企业级项目导航:⬅️ 08-切块策略落库 | 09-白话讲解 | ➡️ 10-策略推荐与方案持久化