--- title: "01-文档上传到RAG检索完整链路讲解" created: 2026-05-21 aliases: - 文档上传到RAG检索完整链路讲解 tags: - 项目 --- # 文档上传到RAG检索完整链路讲解 ## **一、先把整条链路的位置和切分点立起来** 这条链路解决的事情很具体:**用户从前端点"上传"那一刻起,到这份文档"解析完成、切块策略也推荐好、随时可以进入索引构建"为止,中间发生的所有事**。 整条链路被一刀切成两段: **第一段是同步上传**——用户点了上传按钮、到接口返回响应这中间发生的事。这一段的设计目标只有一个:**接口快,亚秒级返回**。重活一概不干。 **第二段是 Kafka 异步处理**——接口返回之后,消费者后台慢慢跑的那一长串。Tika 解析、结构树提取、画像生成、策略推荐,全在这段里。 为什么要这么切?因为这两段的诉求根本不一样。 上传接口必须快,因为它直接挂在前端用户的手指头上。用户点完按钮等三十秒看不到反馈,他要么以为系统挂了再点一次,要么直接走人。所以上传接口能做的事被严格限定在四件——校验、存文件、写记录、发消息——加起来全是网络 IO 和数据库写入,CPU 几乎不动,能控制在一秒内。 后面的解析就完全不同了。Tika 解析一份大 PDF 可能就要十几秒,结构树提取里如果调到 LLM 做歧义消解又要几秒,画像生成再调一次 LLM 又要几秒,最后还要算 token、评结构等级、推策略——加起来跑个几分钟都正常。**这种活儿如果同步做,用户那边浏览器早就超时了。** 所以这条链路贯穿一个核心思想:**轻同步、重异步**。同步只做"接进系统",异步才是"真正干活"。 ## **二、同步上传段:七步走,每一步都有它的位置** ### **第 1 步:文件校验** 入口在 `DocumentManageController.upload()`,第一件事就是校验——格式对不对、大小超不超。 这一步看着简单,但要放在最前面。**任何不该进系统的东西,都要拦在系统门外,不能让它污染后面的存储和数据库**。校验失败直接返回 400,也省得后面所有步骤白做一遍。 ### **第 2 步:读字节、生成文档 ID** `DocumentManageServiceImpl.getFileBytes()` 把文件内容读进内存,同时给这份文档分配一个全局唯一的 documentId。 这里有个细节值得讲——**ID 是在写库之前就生成好的,不依赖数据库自增**。原因是后面 MinIO 的对象路径要用 documentId 拼接,如果等数据库 insert 完拿自增 ID,对象存储和数据库就要绑成一个事务,太重。**先生成 ID、再存对象、再写库**——三件事彼此解耦,每一步失败的爆炸半径都小。 ### **第 3 步:上传到 MinIO** `DocumentStorageService.uploadOriginalFile()` 把原始文件丢到 MinIO。 为什么要用对象存储而不是直接塞数据库?因为文件可能几十 MB 甚至几百 MB,**大文件存数据库会把主从复制、备份、查询性能全部拖慢**。MinIO 这种对象存储天生就是干这个的——大文件吞吐高、分布式、便宜。**数据库只存路径,内容存对象存储**,是这种系统的标准切法。 ### **第 4 步:构建文档主记录** 在内存里 new 一个 `SuperAgentDocument` 实体,字段填好。 这里特别要看的是 ****`parseStatus = PARSING`****——还没真正解析,状态就先标成"解析中"。为什么?因为这条记录一旦落库,前端就能查到这份文档的存在了,**必须给一个能反映真实状态的字段**——告诉前端"这份文档系统已经收到,正在解析"。如果一开始就标成 `PARSE_SUCCESS`,前端会以为已经能用,结果点进去查不到任何切块;如果什么都不标,前端就不知道该展示什么。**中间态是为了让前端有东西可展示**。 ### **第 5 步:构建任务记录** new 一个 `SuperAgentDocumentTask`,taskType 填 `PARSE_ROUTE`(这次任务是"解析+路由"),taskStatus 填 `NEW`(已创建、还没被消费者接手)。 这里就要回答一个常见的疑问:**为什么不把状态字段都堆在 document 表里?为什么要分一张 task 表?** 因为 document 是"资产",task 是"动作"——两者的生命周期完全不一样。 一份文档可能被解析很多次(重新上传新版本、策略变了要重跑、解析失败要重试),但 document 记录始终只有一条。每次解析都是一次新的"动作",都对应一条新的 task 记录。如果把这两个状态混在 document 表上,重新解析时要么覆盖历史、要么互相打架。拆成两张表,**document 反映"这份资产现在怎么样",task 反映"这次动作进行到哪了"**——视角清晰、互不干扰。 第三张表 task\_log 再补一刀,记录每一步的事件流——什么时候开始、什么时候完成、哪里出错。**资产、动作、证据,三张表各管一个维度**。 ### **第 6 步:手动事务提交(这一步是整段同步的核心)** `TransactionTemplate.execute()` 把 document 和 task 的 insert 包在同一个事务里,事务结束、提交、然后才去发 Kafka 消息。 为什么不用 `@Transactional` 注解?为什么要手动控制事务边界? 因为这里要解决的是一个特别隐蔽但特别致命的顺序问题:**Kafka 消息一定要在数据库事务 commit 之后才能发**。 设想反过来——事务还没提交就把消息发出去了。消费者那边手速一快,拿着 documentId 去查数据库——查不到。因为这边事务还没提交,记录对它不可见。结果就是"消息已经投递了、消费者已经消费了、但数据库里啥也没有",任务直接丢。 `@Transactional` 是基于 AOP 切面的,事务边界的精细控制比较受限。`TransactionTemplate` 让你能**精确控制"事务在哪一行提交"——提交完之后才执行下一行的 Kafka 发送**。哪怕 Kafka 发送失败,前面的数据是已落库的,可以靠定时任务扫"长期处于 NEW 状态的 task"做补偿重试。**先 DB 后消息**,是分布式系统里处理"本地事务+消息队列"的一个铁律。 但要老实说——这套朴素方案不是百分百严格的分布式一致性。事务 commit 之后、Kafka 还没发出去这一瞬间如果服务挂了,消息就丢了。完美方案是事务消息(RocketMQ Transactional Message)或者本地消息表。但工程上**接受"小概率不一致+消费端幂等+定时补偿"的组合,性价比最高**。 ### **第 7 步:发 Kafka 消息** `kafkaProducer.sendParseRoute()` 把消息发出去。消息体里**只有 documentId 和 taskId 两个字段**,别的什么都不放。 为什么消息这么瘦? 因为 **Kafka 不是数据库,它的角色是"通知",不是"数据"**。消息只负责告诉消费者"该干活了,文档 ID 和任务 ID 在这里",至于这份文档叫什么名字、什么类型、谁上传的——消费者自己回数据库查就行。 胖消息的两个坑:第一是消息体大,Kafka 网络压力高;第二也是更要命的,**消息里的数据可能跟数据库里的最新状态不一致**。比如消息发出去那一刻文档名是 A,几秒后管理员把文档名改成了 B,消费者拿着旧文档名 A 去处理,处理结果就错了。**真实状态永远以数据库为准**,消息只传指针。 到这里同步段就结束了,接口返回成功响应,整段耗时压在亚秒级。剩下的事 Kafka 消费者后台慢慢干。 ## **三、Kafka 异步段:从拿到消息到推荐方案落库,21 步全过** ### **第一小段:加载上下文 + 文件解析(步骤 8-11)** #### **第 8 步:消费入口** `DocumentKafkaConsumer.consumeParseRoute()` 接到消息。 这层故意做得**非常薄**——反序列化消息、调一下 `handleParseRoute(documentId, taskId)`,就完了。真正的业务逻辑全在 `handleParseRoute` 里。 为什么要这么分?两个好处:第一,**Consumer 这层是 Kafka 框架细节集中地**——监听配置、序列化、消费组——业务代码混进来会让这层很难维护。第二,****`handleParseRoute` **抽出来之后,可以被多个入口复用**——除了 Kafka 触发,后台管理界面里"重新解析"按钮、定时任务做补偿,都可以直接调它,不用非走 Kafka 不可。 #### **第 9 步:加载上下文 + 推进状态** 进 `DocumentAsyncProcessServiceImpl.handleParseRoute()`,第一件事就是用 documentId 和 taskId 回查数据库,把 document 和 task 完整对象拿出来——**消息里只有 ID,数据从 DB 来**,呼应前面瘦消息的设计。 拿到对象之后,把 task 状态从 `NEW` 推到 `RUNNING`。这个状态切换的意义是**让监控可以一眼看出系统状态**:NEW 一直堆积说明 Consumer 消费跟不上,RUNNING 一直堆积说明单个任务处理太慢——两种问题对应的处理动作完全不同。 #### **第 10 步:从 MinIO 重新下载原始文件** `storageService.downloadObject()` 把之前存进去的原始文件捞回来。 **上传接口里不是已经把文件字节读到内存里了吗?为什么不直接把字节传过来?** 因为**上传接口和异步消费完全是两个进程、两个线程、两次执行**。上传接口早就 return 了,它的内存里的字节早就被 GC 回收了。异步消费这边只能拿到消息里的 documentId,**唯一拿到原始文件的方式就是回 MinIO 下载**。这也是为什么前面要把文件存对象存储——它是异步消费唯一的真实数据源。 #### **第 11 步:Tika 解析** `DocumentParserService.parse()` 底下走的是 `TikaDocumentParserService`,用 Apache Tika 把不同格式的原始文件——PDF、Word、Excel、HTML、Markdown——统一抽成纯文本。 但这里要强调一点:**Tika 抽出来的文本不能直接用**。里面常见一堆问题——多余空格、PDF 的硬换行、页眉页脚、控制字符、乱码字符。所以解析阶段还要做文本清洗,把这些噪声去掉。这一步看着不起眼,但**整个知识库的质量地基就在这里**——清洗没做好,后面的标题识别、切块、向量化全部受影响,垃圾进、垃圾出。 ### **第二小段:结构节点提取——四步流水线(步骤 12)** 这一步是整条链路里最值得讲的部分。它在做一件事:**把扁平的纯文本,识别成一棵带父子关系的文档结构树**。 很多人做 RAG 是直接把纯文本丢去切块,省了这一步。但这样会丢一个非常关键的信息——**章节归属**。用户问"第三章讲了什么",没有结构树根本答不上来。 入口是 `DocumentStructureNodeExtractor.extract()`,里面拆成四步: **第一步:信号提取**(`DocumentStructureSignalExtractor`)。逐行扫描清洗后的文本,用 15 种规则识别每一行的"角色"——是 Markdown 标题(`#` 开头)、是中文章节("第 X 章")、是多级编号("1.2.3")、是中文大纲("一、")、是列表项、是步骤、还是普通正文。这一步原则是**宁可多标、不要漏标**——漏掉一个标题后面就补不回来了,多标了后面还能在歧义消解里修正。 **第二步:歧义消解**(`DocumentStructureAmbiguityResolver`)。有些行天生有歧义。比如 "1、项目背景" 这种——可能是一级标题,也可能只是个列表项;比如 "一、" 开头的——可能是大纲,也可能就是序号。这种规则定不下来的,就交给 LLM 做二次判定。 **这就是规则+LLM 混合策略的核心场景**。规则负责大部分确定性场景——毫秒级、零成本;LLM 只处理边界和歧义的少量行——慢、贵、但量可控。如果每一行都问 LLM,成本和延迟都扛不住,稳定性还不如规则;但反过来全靠规则,那些天生有歧义的行又判不准。**两者搭配是性价比最高的组合**。 **第三步:层级构建**(`DocumentStructureHierarchyResolver`)。把前面识别出的扁平信号组装成父子关系。看到 1、1.1、1.1.1、1.2、2 这样的序列,要能正确判断 1.1.1 是 1.1 的儿子、1.2 还是 1 的儿子、2 和 1 是兄弟。实现上用一个 section 栈做线性扫描——栈本身就编码了从根到当前位置的完整路径,遇到新标题做"弹栈到合适层级、再压栈"两个动作。**用栈这种数据结构让算法变简单**,比靠正则硬切优雅得多。 **第四步:树校验**(`DocumentStructureTreeValidator`)。前面的构树过程不可能完美——可能有重复层级要折叠、有非法的父子关系要修复、深度要重算、规范化路径要重建。这一步做"体检+修复",确保产出的树干净可用。 最后输出的每个节点上都带两套路径:****`canonicalPath` **给机器用**——格式稳定、做唯一定位;****`sectionPath` **给人看**——用真实标题拼起来,比如"产品手册 / 第二章 系统设计 / 2.1 架构设计",前端目录展示和 RAG 引用来源都用它。两套路径职责不同,不是冗余。 ### **第三小段:统计、切分、质量评估(步骤 13-16)** #### **第 13 步:标题数量统计** `countHeadings()` 数出文档里有多少个标题节点。这个数后面要决定结构等级——5 个以上算结构清晰、2 个以上算有一定结构、再少就是结构差。 #### **第 14 步:段落切分** `extractParagraphs()` 把文本按段落切开。这一步是**预处理**,给后面的统计和切块提供基础单位。 #### **第 15 步:统计 + 质量评估** 这里实际跑了三个评估: ****`estimateTokenCount()`**** 估算 token 数。这里不用真正的 tokenizer(比如 tiktoken),而是用一个简化算法——中文按字算、英文按词算、其他字符按 4:1 估。**真 tokenizer 慢、重、还和模型绑定**,这里 token 只用来"统计展示",不参与计费、不参与限流,**轻量估算就够,误差 ±20% 完全可以接受**。 ****`evaluateStructureLevel()`**** 评结构等级。基于标题数量分档——HIGH / MEDIUM / LOW / UNKNOWN。这个等级直接影响后面策略推荐时怎么选切块方式。 ****`evaluateContentQuality()`**** 评内容质量。看两个指标——文本是不是太短(可能是解析失败或扫描版 PDF)、乱码字符(`�` 这种替换字符)的比例。质量分 LOW / MEDIUM / HIGH,质量差的话后面策略推荐会更谨慎,甚至考虑 LLM 智能切块兜底。 #### **第 16 步:打包结果** 把所有产物——清洗后的文本、结构节点、统计数据、质量等级——统一封装成一个 `DocumentAnalysisResult` 对象。 这个对象很关键——**它是解析阶段的标准输出**。后面的策略推荐根本不用关心 Tika 怎么解析、文本怎么清洗、结构树怎么提取,只需要看这个结果对象就行。**接口和实现解耦**,后面如果要换解析引擎或者升级结构树算法,只要保证产出的 `DocumentAnalysisResult` 结构不变,下游代码完全不用动。 ### **第四小段:产物持久化(步骤 17-19)** #### **第 17 步:上传清洗后的纯文本到 MinIO** `storageService.uploadParsedText()` 把清洗后的纯文本存成 `parsed-text.txt`,路径写回 document 表的 `parseTextPath` 字段。 为什么要把这份文本独立存一份,而不是每次切块都重新解析原始文件? 因为**解析很贵**——Tika 跑一遍、清洗一遍、结构提取一遍,加起来不便宜。**而切块阶段只需要纯文本**,结构信息已经在数据库里了。所以把"解析的产出"独立持久化,后续切块、索引、摘要、画像都可以直接复用,不用每次重新解析。 更重要的一点——**重复解析可能因为模型/库版本差异产生不一致结果**。把解析产物固化下来,从此切块基于稳定的输入,结果可复现。这是"解析"和"切块"完全解耦的关键。 #### **第 18 步:结构节点落库** `DocumentStructureNodeService.replaceDocumentNodes()`——注意是 **replace 不是 insert**,全量替换。同一文档重新解析时,旧节点先删干净、新节点全量写入。 为什么要全量替换?因为如果只做增量更新,旧结构会残留——前端目录会同时显示新旧标题、Neo4j 图谱会有断裂的边。**全量替换保证结构永远是最新的、一致的**。 #### **第 19 步:导航产物同步** `syncNavigationArtifacts()` 把结构节点同步到两个外部系统:**ES** 和 **Neo4j**。这两个都是可选的,看配置开关。 但这里要把功能讲准—— **ES** 这边写入的是结构节点(章节标题、路径),用于关键词检索。后续用户搜某个章节标题或者关键术语时,ES 能秒级命中并标黄定位。 **Neo4j** 这边把结构节点之间的层级关系投影成图——文档节点连接到一级章节、一级章节连接到二级章节,章节之间还有"前一兄弟、后一兄弟"的边。这样后面用户问"上一章讲了什么"、"第三章下面有哪些内容",可以**靠图谱关系沿着边导航**,而不是只靠向量相似度硬搜。 这一步是为后面 RAG 三通道检索提前埋好的种子。 ### **第五小段:画像生成 + 任务推进(步骤 20-22)** #### **第 20 步:文档画像生成** `DocumentProfileService.generateProfile()` 调 LLM 给整份文档打一组标签——文档类型(手册、规范、合同、纪要……)、主题、摘要、关键词。 相当于给文档发了一张"身份证"。后续用户问问题时,可以**先用画像做粗筛**——比如用户问"差旅报销怎么走流程",先用画像找到"财务类、报销主题"的文档,再在这些文档的 chunk 里做精细检索,比直接全库向量搜效率高得多。 这一步调 LLM,是异步链路里第二次用到大模型(第一次是结构提取里的歧义消解)。两次 LLM 调用都符合**规则+LLM 混合**的核心精神——只在规则搞不定的地方用 LLM,量可控、成本可控。 #### **第 21 步:写解析完成日志** `taskLogService.saveLog()` 写一条 task\_log,记录"解析阶段完成"这个事件。包括完成时间、耗时、本阶段的关键产物 ID。**日志是事后排查的眼睛**——任务表的字段会被覆盖(status 从 RUNNING 变成 SUCCESS),但日志是追加写的,串起来就能完整还原整个执行过程。 #### **第 22 步:任务阶段推进** 把 task 的 `currentStage` 从 `CONTENT_PARSE` 推到 `STRATEGY_ROUTE`。意思是"解析这一阶段干完了,接下来该推荐策略了"。 ### **第六小段:策略推荐(步骤 23-28)** #### **第 23 步:策略推荐** `DocumentStrategyService.recommendStrategy()` 是策略推荐的核心。它根据前面 `DocumentAnalysisResult` 里的指标——结构等级、内容质量、token 数、段落数——决定四种切块策略各自适不适合: | 策略 | 适用条件 | 一句话理由 | | --- | --- | --- | | 结构化切块 | 文件类型适合 + 结构等级高 | 顺着章节切,边界最自然 | | 递归切块 | 全文很长或单段超长 | 长度兜底,防止单块太大 | | 语义切块 | 长 + 段落多 + 质量好 | 按主题边界优化,但成本较高 | | LLM 切块 | 质量差 + 配置允许 | 救火用,不是默认 | **选完策略之后还有一个关键设计**——产出的不是单一切块方案,而是 **Parent / Child 双层方案**。Parent 块大、保留完整上下文、不进向量库;Child 块小、进向量库、负责被检索命中。检索时先在 Child 里召回小块,**通过 parentId 反查到对应的 Parent 大块**,把大块塞给模型回答。**搜小块、读大块**,既精准又完整。 为什么要这样?因为 RAG 里有一个根本矛盾——**检索希望块越小越好**(向量语义聚焦、召回精准),**但回答希望块越大越好**(上下文完整、不答半截)。Parent/Child 双层把这两件事彻底分开——小块管召回、大块管上下文,互不打架。 #### **第 24-25 步:方案主记录 + 步骤明细落库** 写 `SuperAgentDocumentStrategyPlan`(方案主记录,记元信息)和 `SuperAgentDocumentStrategyStep`(方案下的每一步具体操作)。 注意这里**只是把推荐方案落库等用户确认,并不真正执行切块**。状态置为 `WAIT_CONFIRM`,让用户在前端看到推荐结果,必要时还能调整。这种设计把"推荐"和"执行"彻底解耦——推荐层只判断该怎么切,执行层等用户确认后再真正动手。**整个流程更可控,方便后续失败重试和过程回放**。 #### **第 26 步:更新文档主表** 把 document 的 `parseStatus` 从 `PARSING` 切到 `PARSE_SUCCESS`、`strategyStatus` 切到 `RECOMMENDED`。 这两个状态切换之后,前端列表页就能展示"解析成功、待确认方案"——用户点进去能看到推荐方案、能审核、能点"确认"开始下一段索引构建。 #### **第 27 步:任务成功收尾** `finishTaskSuccess()` 把 taskStatus 从 `RUNNING` 切到 `SUCCESS`,记录 `finishTime`。算下来 `finishTime - startTime` 就是这次任务的实际处理耗时,`startTime - createTime` 是它在 Kafka 队列里的等待时间——**三个时间字段拆开记,能精确分析瓶颈在排队还是处理**。 #### **第 28 步:写策略推荐完成日志** 再写一条 task\_log,整个异步链路结束。 --- ## **四、状态机:三个维度同时在流转** 整条链路涉及三个独立的状态字段同时流转。把它们拆开看,逻辑就清楚多了: **parseStatus(文档解析状态):** `PARSING` → `PARSE_SUCCESS` / `PARSE_FAILED` **taskStatus(任务执行状态):** `NEW` → `RUNNING` → `SUCCESS` / `FAILED` **strategyStatus(策略状态):** `WAIT_RECOMMEND` → `RECOMMENDED` → `CONFIRMED` → `EXECUTING` → `EXECUTE_SUCCESS` 注意 strategyStatus **跨链路**——前两步(`WAIT_RECOMMEND` → `RECOMMENDED`)在本链路完成,后面的 `CONFIRMED → EXECUTING → EXECUTE_SUCCESS` 是在下一段索引构建链路完成的。 为什么要拆三个独立字段,而不合成一个?因为**三个维度互不耦合**——解析成功了策略不一定推荐完、策略推荐完了任务不一定结束、任务结束了文档不一定可用。如果合成一个字段,组合数会爆炸——`PARSING_RECOMMENDED_RUNNING` 这种 case 写都写不过来。**多状态字段是工程上的合理设计**。 --- ## **五、异常处理:每一段都有兜底** 整条链路有十几个步骤,每一步都可能挂。处理思路是按发生位置分层: **Kafka 发送失败:** 上传接口直接返回错误。因为这里走的是 `.get()` 同步发送,发送失败会让事务回滚——document 和 task 记录都不会留下。前端看到失败提示让用户重试就行,不会出现"任务在但消息没发"的状态。 **异步解析失败:** 把 parseStatus 标 `PARSE_FAILED`、taskStatus 标 `FAILED`、把错误信息写进 errorMsg 字段、记一条 ERROR 级别的 task\_log。**库里所有相关记录的状态都标记成失败,不留半成品**。同时定时任务会扫长期处于 `RUNNING` 状态的 task 做补偿重试——可能是消费者挂了、可能是 LLM 临时不可用。 **结构提取异常:** 这里做了**特殊处理**——四阶段流水线里如果某一阶段崩了,捕获异常、降级处理,**但不阻断整条主流程**。因为即便结构树没建好,文档的纯文本和画像还能用,切块策略可以降级到递归切块兜底,不至于整份文档作废。这就是**多层兜底**的精神——每个步骤都有降级路径,不让单点失败打断整条主链路。 **消息消费幂等:** 消费端拿到消息后会先查 task 记录——如果 task 不存在(脏消息)直接丢弃;如果 task 状态已经是 SUCCESS(重复消费)直接跳过。这样即使 Kafka 投了两次或者补偿任务和正常消费撞上,结果也不会出错。 --- ## **六、和 RAG 三通道检索的衔接** 讲到最后回到一个根本问题——**这条链路干的活,最终是为了什么**? 答案是:**为 RAG 检索提供高质量的结构化数据**。具体对应三个通道: **向量检索通道**——后面索引构建阶段会基于 Child 块生成 embedding 写入向量库(PGVector 或 Milvus),用户提问时做语义相似度匹配召回最相关的片段。**Child 块的边界、质量、token 数都由本链路推荐的切块策略决定**。 **关键词检索通道**——本链路里第 19 步已经把结构节点同步进了 ES,后面切块的 chunk 也会写进 ES,用户提问时做 BM25 关键词匹配召回包含关键术语的片段。**这一通道补足向量检索的盲区**——产品型号、唯一编号、专有名词这些向量搜不准的字面命中场景。 **图检索通道**——本链路第 19 步同步进 Neo4j 的结构节点关系,可以让用户沿着"章节 → 小节 → 段落"的层级路径做结构化检索,回答"上一章讲了什么"、"第三章包含哪些内容"这类问题。 最终用户提问时,三通道并行召回、用 RRF 之类的算法融合排序、再上一个 Cross-Encoder 精排——**这才是工业级 RAG 检索的全貌**。本链路是这一切的"上游供应商",**产出质量直接决定下游的检索效果**。垃圾进、垃圾出,这条铁律在 RAG 链路里特别明显——所以才要在解析、清洗、结构提取、画像、策略推荐每一步都做扎实。 --- ## **七、收尾** 整条链路的设计核心可以归到八个字——**快进快出、异步干活、状态可控、异常可查**。 - **快进快出**:上传接口亚秒级返回,重活全部丢给后台。 - **异步干活**:Kafka 解耦同步和异步,消费者可以横向扩展。 - **状态可控**:document、task、task\_log 三表分管资产、动作、证据,三维度状态机互不耦合。 - **异常可查**:每个步骤都有日志、有降级路径,失败时库里所有相关记录都标记成失败,不留半成品。 整条链路虽然 28 步看起来长,但**逻辑是一条线串下来的,不绕**。每一步都有它存在的理由——要么是为了让接口快、要么是为了让状态准、要么是为了让 RAG 后面能用得上。讲到任何一步都能把"它在做什么"和"它为什么这样做"两件事讲清楚,就算真正吃透了这条链路。 --- ## **附:极简版(一分钟讲完)** > 这条链路从用户上传到策略推荐落库,分两段——同步上传段和 Kafka 异步段。 > > 同步段做四件事:校验、存 MinIO、写三张表(document、task、task\_log)、发 Kafka 消息。**关键设计是** `TransactionTemplate` **手动控制事务边界、事务 commit 之后才发消息**,避免消费者查不到上下文。消息体只放 documentId 和 taskId,真实数据从数据库查。 > > 异步段在 `handleParseRoute` 里串起来:从 MinIO 重新下载原始文件、用 Tika 解析、做文本清洗,然后跑结构树提取的四阶段流水线——**信号提取、歧义消解、层级构建、树校验**——产出带父子关系的结构节点。**歧义消解这一步用规则+LLM 混合**,规则负责确定场景、LLM 只处理边界歧义,量可控成本可控。 > > 提取完之后做统计和质量评估——token 数、结构等级、内容质量——打包成标准产物 `DocumentAnalysisResult`。然后把清洗后的纯文本独立存到 MinIO、结构节点全量替换落库、同步到 ES 和 Neo4j 给后续关键词检索和图检索用、再调 LLM 生成文档画像。 > > 最后基于 `DocumentAnalysisResult` 推荐 **Parent/Child 双层切块策略**——Parent 大块管回答上下文、Child 小块管检索召回,搜小块读大块。方案落库等用户确认,确认后才进入下一段索引构建。 > > 整条链路三个状态字段同时流转——parseStatus、taskStatus、strategyStatus,三表分管资产、动作、证据。任何一步失败由统一兜底处理,库里永远没有半成品。 --- **企业级项目导航**:⬅️ [[00-聊天系统整体架构讲解|00-聊天系统整体架构讲解]] | 01-文档上传到RAG检索完整链路讲解 | ➡️ [[02-执行计划准备的入口与整体流程|02-执行计划准备的入口与整体流程]]