索引构建链路白话讲解

一、开场:把这一段链路的位置先讲清楚

前面那块讲的是"文档怎么从原始文件变成结构化资产"——上传、解析、清洗、提取结构树、推荐切块策略。那一段的终点是:策略推荐方案已经落库,状态是 WAIT_CONFIRM,等用户在前端点确认。

这一块从"用户点了确认、然后点构建索引"那一刻开始。

终点是什么?终点是这份文档真正变成可以被 RAG 检索的样子——切块切好了、向量算好了、PGVector 写好了、ES 关键词索引也建好了,所有状态机推到终态。用户从这一刻起,就可以拿这份文档去问答了。

所以这一段的角色用一句话讲:把"已审核的方案"变成"可被检索的索引"。中间夹着切块、向量化、双索引并行写入这些重活。

整体可以拆成五段来讲:同步入口(接客)→ 异步初始化(开工)→ 切块(拆文本)→ 向量化和写索引(落地)→ 收尾或失败兜底(结账)


二、同步入口:接口故意做得很轻

用户点"构建索引",前端发一个 POST 请求过来,里面就三个字段:documentIdplanIdoperatorId

这个接口表面上看就是创建个任务、发个 Kafka 消息,但里面藏了几个特别值得讲的设计。

第一个细节:为什么 documentId 和 planId 都要传?

只传 documentId 不行吗?后端不是有当前生效的 currentPlanId 吗?这里其实是在防一种很微妙的状态过期问题。设想用户在前端页面打开方案 A,但管理员后台把方案改成了 B(currentPlanId 被刷新了),用户对页面上 A 点了确认。如果只传 documentId,后端一查 currentPlanId 就是 B,默默地用 B 给用户构建了索引——但用户以为自己确认的是 A

所以前端必须显式带 planId 上来,后端校验"传上来的 planId 是不是当前生效的"。不一致就拒绝,让前端弹窗"方案已变更,请刷新"。这是乐观锁思想——把"语义错误"挡在门外,而不是默默执行错误的版本。

第二个细节:四道校验的顺序很讲究。

校验文档存在且解析成功且策略已确认 → 校验 planId 一致 → 校验没有正在跑的索引任务 → 校验 plan 真实存在。这四个校验按失败概率从高到低排,越早能失败的越早返回,避免做无用功。这是后端校验的一个常见排序原则。

其中第三道"防重复提交"特别值得提一下。用户点了一次构建没看到反馈,又点了一次——如果不拦,两个任务并发跑,同一段文本被切两次、向量也被写两次,会让检索召回出现双倍内容、严重污染 RAG 质量。所以入库前先查一次"这个文档当前有没有 NEW 或 RUNNING 的索引任务",有就拒。

但这里要老实说一句:这只是业务级幂等,不是数据库级幂等。理论上两个请求毫秒级同时进来,都查到"没有运行中的任务",然后都 insert——这是经典的 TOCTOU 竞态。要做绝对防并发得上唯一索引或者分布式锁,但实际产品里这种概率极低,加上 Consumer 端有去重保护,业务级校验已经够用。工程上选"足够好"而不是"完美",这是一种务实判断,面试官听到这种权衡会加分。

第三个细节:策略快照要固化到任务上。

任务表里有个字段叫 strategySnapshot,存的是当前 plan 的方案快照。明明 task 已经有 planId 指向 plan 表了,为什么还要冗余存一份快照?

考虑这个时间线:t0 任务创建,plan 100 的内容是 A;t1 Kafka 消息发出去;t2 用户在前端"反悔"了,把 plan 100 改成了 B;t3 Consumer 才真正开始消费。如果 Consumer 实时去 plan 表查,拿到的是 t3 时刻的 B,但用户在 t0 确认的是 A——执行结果和用户预期不一致。

把快照固化到任务上之后,Consumer 直接读 task.strategySnapshot,拿到的就是 t0 那一刻的 A,按用户当时确认的方案执行。

这背后的思想叫事件溯源——任务记录本质是一个"已发生的事件",事件应该是不可变的。订单创建时商品价格冗余存(防止商品涨价后历史订单变价)、合同签约时条款冗余存(防止条款修订影响已签合同)、任务触发时配置冗余存——都是同一个套路。冗余多存一份字符串,换来的是确定性和可追溯性,这笔账划算。

第四个细节:kafkaTemplate.send().get() 后面这个 .get() 不能省。

kafkaTemplate.send() 默认是异步的,调完立刻返回,不等 broker 确认。这就有风险——应用以为消息发出去了,实际可能因为网络问题压根没发,结果就是任务记录已经 insert 了,但消息永远不会被消费,任务永远卡在 NEW。这就是"任务存在但永远不会执行"的不一致。

加上 .get() 之后变成同步发送,会阻塞到 broker ack 才返回。如果发送失败 .get() 抛异常,外层 Spring 事务回滚——task insert、document update 全部撤销,前端收到的是"Kafka 发送失败"。这样保证了:要么任务创建+文档状态切换+消息投递三件事一起成功,要么一起失败

代价是接口慢了几十毫秒,但对"构建索引"这种低频操作来说,正确性远比性能重要。

这里还要再老实一句:即使加了 .get(),也不是百分百严格的分布式事务——.get() 返回成功之后、本地事务 commit 之前如果数据库挂了,事务回滚但消息已经发出去了,Consumer 消费时会发现 task 不存在。完美方案是事务消息或本地消息表+定时补偿。这套代码用最朴素的"先 insert 再发消息+同步 .get()",靠 Consumer 端的幂等保护兜底——接受小概率不一致+消费端幂等的组合,性价比最高


三、异步初始化:Consumer 接到消息之后先把状态切起来

Kafka 消费端的设计延续了"Consumer 做得很薄"的原则——只负责反序列化和转发,真正业务在 handleIndexBuild 里。

handleIndexBuild 进来第一件事是重新查 document、task、plan 三张表。有人会问:消息里不是已经有 ID 了吗?为什么还要查?因为 ID 只是指针,不是数据。后续要更新这些记录、要拿 strategySnapshot、要拿 parseTextPath,都得有完整对象。

如果三个里有任何一个查不到——可能是脏消息、可能是记录被删——这里只打 warn 然后 return,不抛异常。这是一个有意为之的设计:脏消息再重试多少次都还是脏的,return 让 Kafka 认为消费成功(commit offset),别让脏消息无限占用资源。这种"业务级消费成功 vs Kafka 层投递成功解耦"的思路,是消息队列编程里的一个常用套路。

接下来三件事:把 task 状态从 NEW 推到 RUNNING、currentStage 推到 CHUNK_EXECUTE、记一下 startTime;document 的 indexStatus 推到 BUILDING;step 表里这个 plan 下所有步骤一次性推到 EXECUTING。

这里有个反直觉的设计点:step 状态是粗粒度推进的——所有步骤一起 EXECUTING、一起 EXECUTE_SUCCESS。直觉上每步独立标更清晰,但实际上不行。因为切块流水线是嵌套调用的——父块用结构切块切出 5 个父块,每个父块内部再调子块的 LLM 切块、再用递归切块兜底。**子块的 LLM 步骤被反复调用 5 次,状态怎么标?标成 RUNNING → SUCCESS → RUNNING → SUCCESS 反复横跳?**没意义。

所以选了粗粒度——代价是看不到具体卡在哪一步,回报是逻辑简单不会出错。如果以后真要精细监控,再单开一张事件流式的日志表,不污染 step 表本身。简单可靠,是工程取舍的常见落点。


四、切块:整条链路里最重的一段计算

切块这块是真正能讲出深度的地方。先讲整体设计,再展开四种策略,最后讲降级思想。

4.1 为什么切块要"先纯计算、后批量落库"

很直觉的写法是边切边写——切一个 chunk 就 insert 一条。但这套代码刻意把切块单独抽出来,所有候选都先在内存里攒着,最后批量入库。原因有三个。

第一,切块过程不稳定。四种策略叠加,LLM 可能超时、限流、返回乱码;语义切块依赖 embedding 可能失败。**边切边写的话,切到一半挂了,库里已经写了 50 个 chunk——重试时怎么办?清理还是去重?**先纯计算后落库,切失败内存数据扔掉就行,库里干干净净。

第二,切完才能做全局视角的事——全局去重、全局排序、统一编号。边切边写就只能做局部清洗。

第三,后续阶段对成块结构有完整性要求——向量化是按父块批量处理的,必须等父子映射全部建好。

这种"计算-提交"分离,是处理多步骤不稳定流程的经典模式。

4.2 数据从 MinIO 重新下载,而不是从原始文件重新解析

切块用的输入文本,是从 MinIO 直接下载之前解析阶段已经清洗过的纯文本(parsed-text.txt),不是重新跑一遍 Tika

原因有三:解析阶段已经做过的清洗结果是稳定的,重复跑浪费算力;Tika 不便宜,重复解析对大文档很慢;重复解析可能因为库版本差异产生不一致结果,破坏可复现性

把"解析"和"切块"完全解耦之后,任何一边的改动都不影响另一边——切块策略升级不用重新解析所有文档,解析逻辑改动也不影响已有的 chunk。

4.3 Parent / Child 双层流水线

上一次我们已经讲过 Parent/Child 的核心思想——搜小块、读大块,解决检索希望块小、回答希望块大的根本矛盾。这里讲一下代码层面是怎么落实的。

整体长这样:父块流水线和子块流水线各自独立——父块流水线决定整篇文档怎么切成父块,子块流水线决定每个父块怎么切成子块。两条流水线互不干扰。

执行顺序是嵌套的:先用父块流水线产出"父块种子"列表,然后遍历每个父块,对每个父块单独跑一遍子块流水线产出它的子块列表,最后打包成 ParentBlockCandidate

这里有几个细节:

子块切分必须以父块为输入。换句话说子块的文本必须包含在父块文本里。这个约束不能破——破了之后检索时通过 parentId 反查父块,父块里找不到子块的内容,模型读到的上下文就缺了关键部分。

子块为空时用父块兜底。如果某个父块本身只有一两句、子块流水线切完一个都没剩下,会克隆一份父块当作唯一子块。否则这个父块就成了"死内容"——没有任何子块进向量库,永远不会被召回。宁可检索粒度粗一点,也要保证每个父块至少能被召回

清洗做了三轮——父块种子层一轮、每个父块的子块层一轮、最终全局父块列表一轮。三轮范围不同:第一轮在父块层去重,第二轮在单个父块内的子块层去重(不能跨父块去重,因为不同父块下的相同文本语境不同),第三轮在加完子块后再做一次全局去重,把那些"原本元数据略有差异、加完子块才发现重复"的父块清掉。

4.4 父块种子的三条路径——结构优先、降级兜底

父块怎么切,看方案配置。代码里走三条路径:

**路径 A:方案里有结构步骤,且文档有结构节点,且能筛出种子。**这种是最理想的——直接从前面解析阶段提取的结构树拿章节节点当父块边界。比如"第一章""第二章""1.1 节"这些天然边界,每个都是一个父块种子。然后剔除已经执行过的结构步骤,把剩下的步骤(比如递归切块)继续在这些种子上跑——用结构定边界、用递归控长度。某个章节如果有 1 万字太长,递归切块会把它再切小。

**路径 B:方案里要求结构切,但结构节点筛不出种子(比如纯散文、聊天记录、会议纪要这种没明显标题的文本)。**这种就降级到完整流水线——直接走递归或语义。这里降级是无声的——任务不会失败,只是切块策略降到一档。

**路径 C:方案里压根没要求结构步骤。**直接跑完整流水线。

这三条路径体现了一个核心原则:结构优先、降级兜底。结构信号好的时候用结构,不好的时候自动退到长度兜底,不会让任务垮掉。

4.5 四种切块策略的实现要点

切块引擎叫 executePipeline——按 step 顺序串行执行,每步输出作为下一步输入。每步之间还都嵌一次清洗。

四种策略的核心是这样:

结构切块——逐行扫描+标题栈。这是最值得讲的一种。代码用了一个双端队列当栈维护当前的标题路径。逐行扫文本,每行先丢给一个分类器判断是不是标题(九级正则按优先级匹配——Markdown 标题、附录、"第 X 步"、"第 X 章"、多级数字编号、中文大纲、单级数字、列表、正文,顺序很讲究,比如"第 1 步"必须在"第 X 章"之前匹配,否则会被误判为章节)。

遇到标题就先把当前累积的正文 flush 成一个 chunk,然后做一个栈回退——把栈里所有 level ≥ 当前标题 level 的元素全弹出,再把当前标题压进去。这样栈里永远是从根到当前位置的完整路径,栈本身就编码了层级结构,不需要额外维护

举个例子:栈是 [第一章, 1.1节, 1.1.1小节],遇到 "1.2 节" 这个二级标题,循环弹出 1.1.1小节、1.1节,剩 [第一章],再压入 1.2节,变成 [第一章, 1.2节]String.join(" > ", 栈) 就是当前的章节路径。这是用合适的数据结构让算法变简单的一个典型例子。

里面还有个特别细的点——回退条件是 >= 而不是 >。少这一个等号,遇到同级标题时就不会弹出当前同级,会被嵌套成父子关系,整个路径就乱套。这种细节在面试里被追问的概率很高。

如果整篇文本一个标题都识别不出来,结构切块自己降级到递归切块——单策略内部的降级

递归切块——四级降级算法。这种策略放弃理解语义,只保证一件事:把文本切到不超过阈值,同时尽量保留自然边界

四级是这样的:先按段落(双换行)拆,拆得开就合并到目标长度;拆不开降到按行;再拆不开降到按句子;句子再拆不开就硬切固定窗口。优先用最自然的边界,实在不行才硬切——这个顺序保证切出来的块对人类阅读尽可能友好。

合并也有讲究——不是按 maxChars 直接切,而是先按边界拆碎再合并到目标长度。这样既能保留自然边界,又能控制块大小。逆向思维

固定窗口那一档还要做 overlap——相邻窗口重叠 20% 左右。原因是切块边界可能正好把"高性能索引系统"和"包含三个模块"切开,单独看哪一块都不全,加 overlap 之后边界两边都能完整召回。用空间换召回率

这里还有个细节——overlap 配置必须强制小于 maxChars,否则 step 为 0 会死循环。代码里用 Math.min(configuredOverlap, maxChars - 1) 做防御。对配置不信任,这是工程上的一个好习惯。

语义切块——Jaccard 相似度+双条件触发。这是个反直觉的选择——语义切块听起来应该用 embedding,但代码里用的是 Jaccard(两个词集合的交集除以并集)。

为什么?因为 embedding 又慢又贵又不确定,Jaccard 又快又免费又稳定。对"语义切块"这种轻量边界判断,Jaccard 性价比远高于 embedding。如果对精度有更高要求,直接上 LLM 切块。语义切块填的是中间档——比纯字符切聪明,比 LLM 便宜。

切的时机有两个条件,任一满足就切:累积长度超上限(硬上限),或者累积长度过了下限+当前句和累积块的 Jaccard 相似度低于阈值(主题跳变)。下限那个门槛是为了避免开头两句话主题不同就立刻切,保证每块至少有足够内容

LLM 切块——最聪明也最贵,三层防护。LLM 切块靠大模型理解语义来定边界,效果最好,但调用慢、贵、不稳定。所以做了三层防护:

第一层是配置开关——llmEnabled = false 或者模型实例没注入,直接降级到语义切块。开发环境没配 LLM 不能崩、线上限流时能临时关掉,这是一个容错优先的设计。

第二层是预切分——LLM 有 context window 限制,超长的段先用递归切到 4000 字符这种安全长度再喂给 LLM。

第三层是单段降级——某一段调用 LLM 失败了,这一段降级到语义切块,其他段不受影响。失败的爆炸半径控制在单段。

四种策略每一种内部都有"我搞不定时怎么办"的预案。这种多层兜底的设计,是这套代码贯穿始终的一个思想。

4.6 父子块阈值不一样

最后一个值得提的小细节——同一种策略在父块和子块下行为不一样,靠透传 pipelineType 这个参数控制阈值。父块阈值大(比如 4000 字符),保留大上下文;子块阈值小(比如 800 字符),精准检索。一份代码两套行为,比写两套策略类干净得多。


五、向量化和写双索引:把切好的块真正变成索引

切块切完,候选块还在内存里。下一步是真正落库+向量化+写 ES。

5.1 先做最后一轮过滤,候选转实体

切块后处理这一步,先把无效父块过滤掉——父块本身有内容、子块列表非空、子块列表里至少有一个有内容的子块(用 anyMatch 而不是 allMatch——只要有一个有效就放过父块,再在内层循环里精细过滤每个子块。宽进严出)。

然后做 buildParentChildEntities——把内存里的候选对象转成数据库实体,做四件事:分配全局唯一 ID、建立父子关系、生成全局递增的 chunkNo、估算 token 数。

这里有几个值得讲的设计点:

chunkNo 是全局递增的,不是父块内编号。直觉上每个父块下的子块从 1 开始编号好像更对应父子关系,但实际选了全局递增——父块 1 的子块是 1、2、3,父块 2 的子块是 4、5,父块 3 的子块是 6、7、8、9。理由是:检索结果按 chunkNo 排序就是原文顺序、用户看引用时一眼知道在文档哪个位置、全局唯一可独立做主键。概念上方式 A 更对齐父子关系,工程上方式 B 更好用——很多设计要在概念美观和工程实用之间做选择,这种地方面试官爱问。

父块上冗余存 startChunkNo / endChunkNo 区间。父块详情页要展示"这个父块包含 chunk 5-9",不冗余的话每次都要 SELECT MIN/MAX FROM chunk WHERE parent_block_id = ?。冗余存了之后直接读,跟 strategySnapshot 是一个套路——反范式换查询性能

子块继承父块的 sectionPath。父块是结构切块产出的(带"第一章 > 1.1节"的路径),子块是递归切块产出的(只是按字符切,没有路径),子块继承父块路径,保证检索引用时仍能展示章节信息。这是信息向下传递的常用手法。

父块先入库、子块后入库。chunk.parentBlockId 引用 parent_block.id,业务上要保证被引用的先存在。即使中间崩溃,库里也是"父块完整 + 子块部分"——所有子块引用的父块都还在,数据完整性保持。如果反过来子块先写,崩溃时会留下"孤儿子块"。

5.2 向量化:批量调 embedding+PGVector upsert

向量化是这一段最重的活——既要调外部模型,又要写大量数据(向量比文本大得多)。

批大小是 10。太小每次都一次 API 调用网络开销爆炸,太大单次失败爆炸半径太大。10 是经验值。

严格的数量校验——embeddingList.size() != currentBatch.size() 立刻抛异常。因为如果错位,chunk[i] 和 embedding[i] 配错——用 chunk 5 的文本搜出 chunk 7 的内容,向量索引整个失效。这种契约违反不能容忍,必须立刻挂掉。

写入用 PostgreSQL 的 INSERT ON CONFLICT DO UPDATE(upsert)。任务可能重试,失败之后部分 chunk 已经写进了向量库。重试时如果用纯 INSERT 会冲突,upsert 让重试天然幂等——重复执行的结果和单次执行一致。这是幂等设计的经典手法。

vectorId 直接复用 chunk.id,但类型用 String。复用主键是因为简单——不用单独维护向量库的 ID 空间,业务库和向量库的对应关系一目了然。用 String 是为了兼容不同向量库——PGVector 是数字主键,但 Milvus、Pinecone、Weaviate 都是 UUID。为多向量库支持留扩展位

metadata 里要存 embeddingModel 名字。这个字段在大版本升级时是救命的——embedding 模型从 ada-002 换成 text-embedding-3-small 时维度可能不同,新旧向量混存检索质量会出问题,通过这个字段能快速找出"用旧模型生成的需要重建"

用专用的 pgVectorJdbcTemplate。意味着 PGVector 是独立的数据库实例,配了独立的 DataSource。业务库和向量库分离,各自独立伸缩——OLTP 业务库优化事务,向量库装 pgvector 扩展、建 HNSW 索引。大型系统里数据库分离很常见

5.3 ES 关键词索引:可选的另一条腿

向量化写完之后,紧接着把同一批 chunk 写入 ES 关键词索引。这一步是可选的——keywordSearchGatewayProvider.getIfAvailable() 拿到的可能是 null。

为什么用 ObjectProvider.getIfAvailable() 而不是 @Autowired(required=false)?因为前者是运行时按需获取,表达"可选依赖"的语义更明确。这是 Spring 里表达可选依赖的标准方式。

但是!可选不等于失败可忽略。代码里 if (keywordSearchGateway != null) 之后没有 try-catch——意思是"网关不存在可以跳过,网关存在但写入失败必须挂"。"我不提供这个能力"和"我提供这个能力但搞砸了"是两回事——后者必须报错,不能让用户以为关键词索引建好了实际却没建成。契约一致性

ES 这边有几个值得讲的细节:

Refresh 策略选 WaitFor。ES 的 refresh 有 NONE / WaitFor / Immediate 三档——NONE 最快但写完不能立即搜到,Immediate 强制刷新对集群压力大,WaitFor 是中间档:等下次 refresh 完成。保证用户建完索引立刻能试用,又不会拖垮集群。

bulk API 必须显式检查 response.errors()。ES Bulk 有个大坑——HTTP 200 不代表所有 item 成功,每个 item 有自己的成功/失败状态。很多人会漏掉这个检查导致部分写入失败但任务标记成功,最后用户搜不到东西还查不出原因。这套代码做了完整的 errors 检查+错误聚合,能精确定位是哪个 chunk 失败、为什么失败。

写入的文档结构里冗余了文档元数据——文档名、分类、标签这些都冗余进了每条 chunk 记录。避免回表——检索时直接命中带元数据的记录,不用再查 document 表。标签字段从字符串拆成数组——方便 ES 的 multi_match 查询。

5.4 双索引互补

向量索引解决"语义相似",关键词索引解决"字面命中"。用户搜"怎么报销",向量索引能召回"费用报销流程"——这种语义近邻关键词索引搜不出来。但用户搜"FORM-2024-001"这种唯一编号,或者按"分类=财务"过滤,关键词索引秒命中——这种向量索引就答不上来。

**两条腿走路,互补,缺一不可。**这正是后面我们讨论 Trilium 那种关键字检索时讲到的 Hybrid Search。这套代码在写入端就把双索引建好了,后续检索链路才有混合召回的空间。


六、收尾或失败兜底

如果一切顺利,收尾很简单——plan 标 EXECUTED、document 的 indexStatus 标 BUILD_SUCCESS、task 标 SUCCESS、step 全部 EXECUTE_SUCCESS、记一条 SUCCESS 日志。所有状态机推到终态,整条链路结束。

但任何一步可能失败——LLM 超时、向量库连接断、ES 写入报错。所有失败由最外层 try-catch 统一接管,做四件事:把 chunk 的 vectorStatus 标 VECTOR_FAILED,避免库里残留"半向量化"的脏数据;step 全部标 EXECUTE_FAILED;document 的 indexStatus 标 BUILD_FAILED;task 标 FAILED 并记录失败原因。

为什么要做这种统一兜底?因为整条链路有切块、向量化、双索引写入、状态推进十几个步骤,每一步都可能挂——如果让每一步自己处理失败,代码会被 try-catch 淹没。统一兜底让正常路径保持线性,失败路径集中处理——是错误处理的一种经典模式。

更重要的是——失败时所有相关记录都标记成失败状态,不会留下"任务说成功但 chunk 是半成品"的鬼状态。运维查这个文档一看 indexStatus = BUILD_FAILED,立刻知道要重建;用户看到失败也能再点一次构建。状态可见、可恢复、可重试


七、一句话收尾

整条索引构建链路,从用户点确认到 RAG 可用,做的事情可以用一句话讲:

先用一个故意做得很轻的同步接口接客(校验+落任务+发 Kafka),然后在异步链路里把切块和落库分开做(切块全部纯计算、最后批量入库),切块本身用 Parent/Child 双层流水线+四种策略多层兜底(结构切优先、递归切控长度、语义切补主题、LLM 切救低质量),最后向量化和 ES 关键词索引并行写入形成 Hybrid Search 的两条腿,所有状态机用一个统一的成功/失败收尾保证库里永远没有半成品。

整条链路贯穿几个核心思想:轻同步重异步、消息只传 ID、事务边界精确、状态机驱动、多层兜底、计算与提交分离、幂等设计、契约一致性。这些不是为了用而用的"架构概念",每一个都对应链路上一个具体的工程问题——把它们从抽象名词还原成具体场景,是这一段讲述能不能"侃侃而谈"的关键


附:一分钟极简版

如果时间紧,可以这样讲:

这一段是用户点构建索引到 RAG 可用的完整链路。同步接口故意做得很轻——四道校验+创建 BUILD_INDEX 任务+把策略快照固化到任务上+发 Kafka 消息,.get() 同步等 broker ack,保证任务和消息要么一起成功要么一起回滚。

Consumer 进 handleIndexBuild,先把 task / document / step 三层状态切到运行态,然后从 MinIO 下载解析后的纯文本(不重新跑 Tika),调 buildParentBlocks 用 Parent/Child 双层流水线切块。切块全部在内存里完成,不边切边写——失败重试代价低、能做全局视角的清洗。

切块策略有四种:结构切用九级正则识别标题+栈维护章节路径;递归切用段落 → 行 → 句子 → 固定窗口四级降级,固定窗口加 overlap 防边界丢失;语义切用 Jaccard+双条件触发;LLM 切三层防护——配置开关、预切分控 prompt 长度、单段失败降级到语义。每种策略内部都有"我搞不定时怎么办"的预案

切块完成后转成数据库实体,全局递增 chunkNo、父块冗余存 chunk 区间、父块先入库子块后入库。然后并行写两条索引——PGVector 用 INSERT ON CONFLICT 批量 upsert 让重试幂等,metadata 里存 embeddingModel 名方便版本追溯;ES 用 bulk API 显式检查 errors,refresh 选 WaitFor 平衡可见性和集群压力。双索引互补,向量管语义,关键词管字面,给后面的 Hybrid Search 留好地基

所有失败由最外层统一兜底——chunk 标 VECTOR_FAILED、step 标 EXECUTE_FAILED、document 标 BUILD_FAILED、task 标 FAILED,库里永远没有半成品状态


企业级项目导航:⬅️ 07-构建索引 | 08-索引构建链路白话讲解 | ➡️ 09-落库向量化收尾