--- title: "09-白话讲解" created: 2026-05-19 aliases: - 白话讲解 tags: - 项目 --- # 白话讲解 > 知识库文档上传后的异步解析和切块策略推荐链路。用户上传文件后,系统不会同步完成解析,而是先做校验、把原始文件上传到 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_CONFIRM`、`stepStatus = WAIT_EXECUTE`。用户可以先看到推荐方案,必要时调整。 这种设计的好处是把"推荐"和"执行"分开了:推荐层只负责判断该怎么切,持久化层负责把方案和步骤落库,执行层等用户确认后再真正切块。**整个流程更可控,也方便后续失败重试和过程回放。** ### **模块价值总结** 这个模块的核心价值有三点。 第一,**上传接口设计成异步处理入口,而不是同步重任务接口**。上传阶段只做文件接入、状态初始化和 Kafka 投递,耗时的解析和策略推荐放到后台异步执行。 第二,**通过 document、task、task\_log 三类数据,把文档资产状态、任务执行状态和过程日志拆开管理**,保证整个链路可追踪、可恢复、可排查。 第三,在解析阶段,**不是简单用 Tika 抽文本,而是在文本清洗之后做结构树提取、统计打分和内容质量评估**,再基于这些画像自动推荐 Parent / Child 双层切块策略,为后面的向量检索、ES 检索、Neo4j 图谱和 RAG 问答打基础。 整个设计贯穿几个思想: | 思想 | 体现 | | --- | --- | | 轻同步重异步 | 上传接口只做初始化,重活全给 Kafka 消费方 | | 消息只传 ID | Kafka 消息体极简,详细上下文以数据库为准 | | 事务边界精确 | TransactionTemplate 手动控制,提交后再发消息 | | 状态机驱动 | 每个节点都有明确状态,前端可展示进度 | | 规则 + LLM 混合 | 高确定性用规则,边界歧义才用 LLM,控成本控延迟 | | 多层兜底 | 每个步骤都有降级策略,不让单点失败打断主链路 | **用一句话收尾:负责的是知识库 RAG 链路最前面那段地基工程——前面打得稳,后面才能问得准。** --- **企业级项目导航**:⬅️ [[08-切块策略落库|08-切块策略落库]] | 09-白话讲解 | ➡️ [[10-策略推荐与方案持久化|10-策略推荐与方案持久化]]