主题 1 · 知识工程优化#
定位:RAG 管线第一层——文档解析、切块、索引,决定了检索质量的上限。切块质量直接约束了下游 rerank、LLM 生成的天花板:块太大语义模糊导致向量检索召回漂移,块太小丢失上下文导致 LLM 理解断裂。
一、通用知识#
1.1 核心概念与原理#
为什么切块是 RAG 的基石
面试怎么讲:
“RAG 里切块质量决定了检索的上限。道理很简单:embedding 模型对一个 chunk 做一次编码,如果这个 chunk 里混了两个不相关的话题(比如第一章的尾巴拼上第二章的开头),那这条向量就是一个语义模糊的平均值——跟谁都有点像,跟谁都不够像。检索阶段用这条向量去 ANN 近邻搜索,召回的就是’凑合相关’而不是’精确命中’。下游不管你 rerank 多精细、prompt 写多好,原始召回漂了,后面只能做局部补救。所以我们团队有一条原则:宁可在切块阶段多花工程量,也不要在后面靠精排和 prompt hack 去弥补。”
切块策略全景
面试怎么讲:
“切块策略从简单到复杂可以分五档。最简单的是固定大小——按字符数切,500 字一块。优点是实现 0 成本,缺点是完全不识别语义边界,一个句子切成两半、一个表格切进两个 chunk 是常态。第二档是递归切块——LangChain 默认的 RecursiveCharacterTextSplitter,按分隔符层级递归(先双换行 → 再单换行 → 再句号 → 再空格),比固定大小好一档但还是不识别表格和代码。第三档是语义切块——逐句计算 embedding 相似度,差异超阈值就断开,理论上最精准但计算成本高(每句话一次 embedding 调用)。第四档是文档感知切块——按 heading/section 结构切,保留文档层级。第五档是 Agentic Chunking——用 LLM 判断每个句子属不属于当前 chunk,最精确但最贵。实际工程选型不是越高档越好,NAACL 2025 有篇实证研究发现固定 200 词切块在部分 benchmark 上和语义切块持平——复杂方案一定要用 eval 证明收益,否则就是过度工程。”
Chunk Size 的权衡
面试怎么讲:
“chunk size 是 precision 和 recall 的 tradeoff。小 chunk(200字)精度高——向量语义集中,检索命中更准;但召回低——一个知识点可能分散在多个小 chunk 里,只命中一个不够用。大 chunk(1000字)召回高——信息更完整;但精度低——语义被稀释,向量检索漂移严重。经验甜区是 200-500 tokens,但这个值不是银弹,需要跟你的 embedding 模型的训练窗口对齐。比如 text-embedding-v3 的 max_tokens 是 2048 但最佳区间在 200-500,超出这个区间性能下降。另一个解法是父子块——子块 400 字用于向量检索精准命中,命中后返回 1500 字的父块给 LLM,兼顾精度和上下文完整。”
元数据增强
面试怎么讲:
“原始的 RAG 只存 chunk 文本和向量,但生产环境下这不够。一个常见的失败模式是:用户问’第三章关于 XX 的规定’,检索只看语义——第一章和第三章都提到了 XX,但语义最近的可能是第一章那条,因为它措辞更接近 query。如果 chunk 上带了章节面包屑(chapter: ‘第三章 > 安全规范 > XX 条款’),检索或 rerank 阶段就能利用结构信息做过滤。实用的元数据至少包括:heading breadcrumb、内容类型(表格/代码/定义/流程)、文档版本号、生效日期、自动标签。RAGFlow 风格更进一步——用 LLM 给每个 chunk 生成关键词再嵌入,但成本不低。”
增量索引
面试怎么讲:
“全量重建是最简单的方案——每次文档更新就清空所有 chunk 重新切分 + embedding。但 embedding 是最贵的操作(按 token 计费 + API 延迟),如果一份 200 页的文档只改了第 5 页的一个数字,全量重建意味着 200 页都要重新 embedding——99% 的计算是浪费的。增量方案用 content hash 做 diff:每个 chunk 算一个 SHA-256,更新时比较新旧 hash,只对 hash 变了的 chunk 做 embedding。代价是需要维护一张 (kb_id, content_hash, vector_id) 的映射表,工程复杂度高一档。我的经验是:文档数量少于 50 篇、单篇不超过 20 页的场景,全量重建就够了(几秒钟);超过这个规模才值得上增量。“
1.2 业界主流方案对比(表格形式)#
切块策略对比
| 策略 | 原理 | 优点 | 缺点 | 适用场景 | 代表实现 |
|---|---|---|---|---|---|
| 固定大小 | 按字符/token 数固定切 | 实现零成本、确定性高 | 跨句跨段、表格切碎 | 原型验证、纯文本 | LangChain CharacterTextSplitter |
| 递归切块 | 按分隔符层级递归(\n\n→\n→.→空格) | 平衡性能与质量、通用性好 | 不识别表格/代码/heading | 通用文档、快速上线 | LangChain RecursiveCharacterTextSplitter |
| 语义切块 | 逐句 embedding 相似度,差异超阈值断开 | 语义完整性最好 | 慢(每句一次 embedding)、依赖模型质量 | 长文章、论文 | LangChain SemanticChunker |
| 文档感知 | 按 heading/section 结构切分 | 保留文档层级、天然语义边界 | 依赖文档格式规范 | Markdown / HTML / Word | LangChain MarkdownHeaderTextSplitter |
| Agentic | LLM 判断每句是否属于当前 chunk | 最精确、跨段落主题识别 | 最贵最慢(每句一次 LLM) | 高价值文档、合规场景 | LlamaIndex AgenticChunker |
| 混合策略(DocMind) | Block 类型识别 + heading 栈 + 父子双层 | 结构化保留 + 精度/上下文兼顾 | 实现复杂度中等 | 多格式企业文档 | 自研 TextChunker |
Embedding 模型对比
| 模型 | 维度 | 多语言 | 中文优化 | 成本 | MTEB 排名参考 |
|---|---|---|---|---|---|
| text-embedding-3-small (OpenAI) | 1536d | 好 | 一般 | $0.02/1M tokens | 中上 |
| text-embedding-3-large (OpenAI) | 3072d | 好 | 一般 | $0.13/1M tokens | 前列 |
| BGE-M3 (BAAI) | 1024d | 多粒度多语言 | 优 | 自部署 | 开源前列 |
| GTE-large-zh (Ali) | 1024d | 中英 | 优 | 自部署/API | 中文前列 |
| text-embedding-v3 (DashScope) | 1024d | 中英 | 优 | 阿里云免费额度 | 中文优 |
面试怎么讲:
“Embedding 维度不是越高越好。3072d 的 text-embedding-3-large 比 1024d 的 text-embedding-v3 在英文 benchmark 上确实好几个百分点,但中文场景 GTE 系列经过中文语料微调,1024d 就能达到甚至超过通用 3072d 模型。而且维度越高,Milvus 索引占用内存越大、ANN 搜索越慢。我们选 1024d 是性价比最优的——精度够用、存储成本低(vs 3072d 省 66% 内存)、和 DashScope 的 rerank 模型同生态无缝衔接。“
1.3 关键论文与技术要点#
| 论文/工作 | 年份 | 核心贡献 | 面试一句话 |
|---|---|---|---|
| Contextual Retrieval (Anthropic) | 2024 | chunk 嵌入前用小模型补一句全局上下文 | ”检索失败率 -49%(叠加 rerank -67%),当前 ROI 最高的检索增强” |
| Parent Document Retrieval (LlamaIndex) | 2023 | 子块精准召回 → 返回父块保上下文 | ”子块 match、父块 feed——精度和上下文兼得” |
| RAPTOR (Sarthi et al.) | ICLR 2024 | 递归摘要构建树状索引,不同粒度检索 | ”解决跨段落主题摘要检索的问题” |
| Late Chunking (Jina AI) | 2024 | 先对全文做 long-context embedding,再按边界切分向量 | ”保留跨 chunk 的上下文信息,无额外推理成本” |
| 5 Levels of Text Splitting (Greg Kamradt) | 2023 | 从字符到语义的五级切块实证对比 | ”切块不是越复杂越好,要用 eval 证明收益” |
| Dense Passage Retrieval (Karpukhin et al.) | EMNLP 2020 | 双塔 encoder 检索取代 BM25 | ”向量检索的奠基工作,证明密集向量胜过稀疏检索” |
Contextual Retrieval(重点展开)
面试怎么讲:
“Anthropic 2024 年发的 blog,核心思路极其简单:在把 chunk 写入向量库之前,用一个小模型(Claude Haiku 级别)给每个 chunk 补一句话——‘本段来自第三章产品规格,讨论的是 XX 型号的耐热参数’。这句话拼到 chunk 开头再做 embedding。为什么有效?因为原始 chunk 经常缺乏上下文——一个 chunk 可能就是’该值不得超过 300 度’,单看根本不知道在说什么。补了一句上下文后,embedding 质量大幅提升。Anthropic 的数据是检索失败率 -49%,叠加 BM25 和 rerank 后 -67%。成本控制用两招:一是用最便宜的小模型生成上下文,二是 prompt caching——全文作为长 prefix 缓存,每个 chunk 只发增量部分。这是当前 ROI 最高的检索增强方案,比微调 embedding 模型简单得多。”
父子块检索(重点展开)
面试怎么讲:
“父子块解决的是切块的精度-上下文悖论。小 chunk(300-400字)向量语义集中,检索命中精准——这是 match 阶段需要的。但 LLM 生成阶段需要更完整的上下文——一个 300 字的 chunk 可能只有结论没有前因,LLM 没法基于不完整信息给出好答案。父子块的做法是:在切块阶段同时产出子块(400字)和父块(1500字),子块写入向量库做检索,命中后去 MySQL 查对应的父块内容返回给 LLM。多个子块指向同一父块时自动去重,只保留最高分那个。LlamaIndex 的 SentenceWindowNodeParser 和 LangChain 的 ParentDocumentRetriever 都是这个思路。“
1.4 常见面试问答#
Q1: 切块大小怎么选?
A: 核心是 precision 和 recall 的 tradeoff。小 chunk(200字)向量语义集中,检索精度高,但一个知识点可能散在多个小块里导致信息不完整。大 chunk(1000字)信息完整但语义被稀释,向量检索漂移。经验甜区是 200-500 tokens,但必须和 embedding 模型的训练窗口对齐——大多数 embedding 模型在 512 tokens 以内效果最好,超出性能下降。另一个更优雅的解法是父子块:子块 300-400 字做检索,命中后返回 1500 字的父块给 LLM,兼顾精度和上下文。最终确认要靠 eval——准备 50-100 条 query-golden_chunk 对,跑 Recall@5 和 MRR 对比不同 chunk size 的效果。
Q2: Embedding 维度越高越好吗?
A: 不是。维度越高理论表达能力越强,但有三个代价:一是存储——Milvus 里 1M 条 3072d float32 向量占 12GB 内存,1024d 只占 4GB;二是检索延迟——ANN 搜索的距离计算和索引构建都和维度正相关;三是边际收益递减——从 768d 到 1024d 的 MTEB 提升明显,从 1024d 到 3072d 的提升很小,尤其是中文场景。选型建议是看 MTEB 中文子集的排名,在精度足够的前提下选最低维度。实际中 1024d 是主流甜区——既够精确又不至于让向量库内存爆炸。
Q3: 怎么处理 PDF 里的表格?
A: PDF 表格是 RAG 的经典难题。纯文本提取器(PDFBox / PyPDF)会把表格按行拼成一堆乱序文字,完全丧失结构。两条路线:一是 layout-aware 解析——用 MinerU、Unstructured、LlamaParse 这类工具做版面分析,识别出表格区域后输出 Markdown 表格格式(
| col1 | col2 |)。二是切块阶段保护——即使解析出了完整的 Markdown 表格,如果 chunker 不识别表格语法,照样会在表格中间切一刀。所以 chunker 必须有 block 类型识别能力:遇到连续的| ... |行,整块保留不切碎,即使超过 max_chunk_size 也保留完整结构。我们在 TextChunker 里就是这么做的——TABLE block 作为原子 chunk,宁可超长也不切碎。这里还有个易踩的坑:版面工具的表格输出格式不止一种——同一个 MinerU,PDF 表格可能出 HTML(<table><tr><td>)、Markdown 文档表格出管道表。chunker 的识别层要同时认这两种形态,否则 HTML 表格会漏识别成普通段落被切碎(我们就踩过,详见 DocMind 实践)。
Q4: 增量更新怎么做?全量重建和增量的权衡?
A: 增量更新的核心是 content hash diff。每个 chunk 算一个 SHA-256 hash,存在 kb_chunk 表里。文档更新时,重新切分产出新 chunk 列表,按 hash 和旧列表比对:hash 相同 = 内容没变,复用已有的 embedding 和 vector_id,只更新 chunk_index 等元信息;hash 不同 = 新增内容,走 embedding + Milvus 写入;旧列表里没被匹配到的 = 内容被删,从 Milvus 按 vector_id 精准删除。权衡点在于:增量方案省的是 embedding 调用(API 最贵的一步),但多了 hash 比对 + 精准删除的工程复杂度。小规模(<50 篇、每篇 <20 页)全量重建几秒钟搞定,不值得上增量;大规模场景下一次改动从 200 次 embedding 降到 1 次,ROI 很高。
Q5: Contextual Retrieval 为什么有效?
A: 因为它解决了 chunk 脱离上下文后语义丢失的问题。一个典型例子:原文第五章写”该参数不得低于 85%“,切块后这个 chunk 只有这一句——embedding 里只有”参数""不低于""85%“这些信号,完全不知道在说什么参数。用户问”产品 X 的良率要求是多少”,这个 chunk 的向量和 query 的语义距离很远,召回失败。Contextual Retrieval 在 embedding 前给它补一句:“本段来自第五章产品规格,讨论产品 X 的良率指标”——现在”产品 X""良率”这些关键信号被补回来了,embedding 质量大幅提升。Anthropic 的数据是检索失败率 -49%(叠加 BM25+rerank 后 -67%)。成本控制用 prompt caching:全文作为长 prefix 缓存,每个 chunk 只发增量部分,token 成本大幅压缩。
Q6: 父子块检索的原理?什么时候用?
A: 父子块本质是把检索和生成两个阶段的最优 chunk size 解耦。检索阶段需要小块(300-400字)——语义集中、向量精准、ANN 命中率高。生成阶段需要大块(1000-1500字)——上下文完整、LLM 理解充分。父子块在切块阶段同时产出两层:子块写入向量库做检索,每个子块记录 parent_chunk_id;检索命中子块后,通过 parent_chunk_id 查到父块,用父块内容替换子块内容送给 LLM。多个子块指向同一父块时只保留最高分的那个(去重)。适用场景:文档段落之间有上下文依赖(制度条文、技术文档——结论依赖前面的定义和前提),纯独立 FAQ 型文档反而不需要。
Q7: 怎么评估切块质量?
A: 切块质量不能直接评估——它通过检索质量间接体现。标准做法是端到端评测:准备 50-100 条 (query, golden_chunk_id) 对,跑 Recall@K 和 MRR。Recall@5 衡量”前 5 条检索结果里有没有包含正确 chunk”,MRR 衡量”正确 chunk 平均排在第几位”。对比不同切块策略时,固定其他环节(同一 embedding 模型、同一检索参数),只换 chunker,看 Recall 和 MRR 的差异。另一个轻量级信号是 chunk 内聚度——对每个 chunk 内的句子两两算 embedding 相似度取平均,内聚度低说明 chunk 里混了不相关的内容。但这个指标只能辅助排查,不能替代端到端 eval。
二、DocMind 实践#
30 秒口述版#
“文档解析层我用 MinerU 云端 layout-aware 解析,把 PDF/PPT/图片统一输出 Markdown——多栏识别、表格保留、公式识别,比 PDFBox 纯文本提取质量高一个量级。切块用自研的 Markdown-Aware TextChunker,先把文本解析为 5 种 Block 类型(Heading/Paragraph/Table/Code/Image),表格和代码块原子保留绝不切碎,Heading 维护栈结构注入章节面包屑。然后做父子双层切块——子块 400 字用于向量检索精准命中,命中后通过 ParentChunkResolver 返回 1500 字父块给 LLM 保上下文。最后是 SHA-256 增量索引——文档更新时按 content hash diff,只对变更 chunk 重新 embedding,200 页文档改 1 页只需约 1 次 embedding 调用。“
详细展开#
背景/痛点(Situation)#
DocMind 面向企业知识库场景,文档格式多样(PDF/Word/PPT/Markdown/图片/网页 URL),文档结构复杂(多栏排版、嵌套表格、代码块、公式)。初版采用 PDFBox + 朴素段落切分(按 \n\n 分段 + 句号切分),暴露三个核心问题:
- 表格切碎:PDFBox 按行提取文本,表格变成一堆无序文字;朴素 chunker 不识别
| ... |表格语法,在表格中间切一刀——检索到半张表完全没法用 - 章节上下文丢失:切块后 chunk 不知道自己属于哪个章节。用户问”第三章关于 XX 的规定”,检索只看语义,第一章和第三章都提到 XX 时经常召回错误章节
- 更新成本高:每次文档改动都全量重建——200 页文档改 1 个字要重新 embedding 200+ 个 chunk,API 调用成本和时间都不可接受
此外,单一粒度切块存在精度-上下文悖论:400 字 chunk 检索精准但上下文不足,LLM 经常基于不完整信息生成偏差答案;1500 字 chunk 上下文充足但向量语义稀释,检索漂移严重。
做了什么(Action)#
1. MinerU 云端 layout-aware 解析(DocumentExtractor + MinerUClient)
用 MinerU 云端 API 替代本地 PDFBox 纯文本提取,支持两条入口:
- 本地文件(PDF/PPT/图片):三步流程——申请 OSS 预签名上传链接 → PUT 上传 → 轮询解析结果,最终下载 zip 抽取
full.md - 网页 URL:提交 URL 任务(
MinerU-HTML模型)→ 轮询 → 同样抽取 Markdown
关键设计:
- 格式分级路由:PPT/图片只有 MinerU 能解析,不可用直接抛错(fail-fast,避免静默无效入库);PDF 优先 MinerU,失败降级 PDFBox 纯文本(
allowFallback=true);DOCX/TXT/MD 走本地解析不依赖云端 - 动态开关:
parser.mineru.enabled通过AiConfigHolder热配置,云端故障时一键切换到 PDFBox 降级,不重启服务 - 统一 Markdown 输出:无论什么格式,解析完都是 Markdown——表格是
| ... |格式,代码有 ``` 围栏,heading 有#标记。这让下游 TextChunker 只需要处理一种输入格式
2. Markdown-Aware Block 切分(TextChunker)
核心创新是把文本先解析为类型化 Block,再按 Block 类型差异化处理:
- 5 种 Block 类型:HEADING / PARAGRAPH / TABLE / CODE / IMAGE。用正则识别
| ... |表格行、``` 代码围栏、#heading、![]()图片引用 - 结构化元素原子保留:TABLE 和 CODE block 永远不切碎——即使超过 MAX_CHUNK_SIZE(600字)也作为单个 chunk 保留完整结构。超长表格会打 warn 日志但不截断(
"表格 chunk 超长: {} 字(保留完整结构)") - Heading 栈维护:用 6 级数组
headingStack[0..5]追踪当前 heading 层级。遇到新 heading 时截断栈到level-1并 push 新标题,构建 breadcrumb(如"第一章 > 第一节 > 概念定义")注入每个 chunk 的chapter字段 - Heading 作为语义断点:新 heading 出现时先 flush 已有 buffer,防止跨章节内容合并到同一 chunk
- Heading 路径注入(全栈):每个 chunk 的 content 开头拼上整条 heading 栈渲染出的完整路径(
# 文档/产品标题\n## 章\n### 节),让 chunk 的可检索文本自带从文档/产品名到当前小节的完整上下文(轻量版 Contextual Retrieval)。早期版本只拼”上次 flush 后新出现的标题”,会导致父级标题(如合订本的产品名 H1)只贴到它后面第一个 chunk、后续同级条款丢失产品名——见 2.2 踩坑,已改为全栈渲染(composeWithHeadingPath) - 纯文本降级:无 Markdown 标记时退化为旧版段落合并 + 句子切分逻辑,向后兼容 DOCX/TXT
超长段落兜底:按中文句末标点(。!?;)切分,保留 50 字重叠。刻意不用英文 . 作分隔符——避免破坏代码中的方法调用、数字小数点、英文缩写。
2.1 HTML 表格识别补漏(一个真实踩坑)
上线后排查一个保险条款知识库(中国人寿个人保险基本条款)的检索质量问题时,发现一个隐蔽的解析缺口:MinerU 解析 PDF 时,表格并不总是输出成 Markdown 管道表(| ... |),而是直接吐 HTML 标签(<table><tr><td>...)。我最初的 Block 识别只有 TABLE_LINE 正则(^\s*\|.*\|\s*$)认管道表,HTML 表格因为不匹配任何结构化正则,被当成普通 PARAGRAPH 处理——于是享受不到”原子保留”待遇,长表格在句子切分阶段被从中间切断。
定位证据是直接查库比对的:该知识库重新解析后,kb_chunk 里有 12 个切片含 <table>/<tr>/<td> 标签,但 has_table(管道表标记)为 0、contentType='table' 也为 0——说明这些 HTML 表格全部沦为普通段落被切碎了。这正好印证了一条原则:切块缺陷不会报错,只会悄悄拉低召回,必须靠观测信号和数据比对才能发现。
修复是在 parseBlocks 里加一个 HTML 表格分支(紧跟代码围栏识别之后):
- 用
<table\b[^>]*>/</table\s*>两个大小写不敏感正则,从<table起始累积到</table>结束,整段作为BlockType.TABLE原子保留——复用既有的 TABLE 装配逻辑,自动获得”超长不切碎 + heading 前缀”待遇,和管道表完全对齐 - 处理 跨多行(逐行累积直到命中闭合标签,未闭合则 warn 后整体保留,和代码块策略一致)和 与正文同行混排(
<table>之前的文字剥离并入上一段、</table>之后的文字留给下一段),避免把正文吞进表格、或把表格切给正文 - 补了两个单测:
htmlTableKeptAsSingleChunk(30 行超长 HTML 表格识别为单个 table chunk、首尾行不丢、带 heading 前缀)和htmlTableInlineWithText(混排正文被正确剥离)
这个坑的价值在于:版面解析工具的输出格式是会变的(同一个 MinerU,PDF 表格出 HTML、Markdown 文档表格出管道表),下游 chunker 不能假设只有一种表格形态。识别层要按”语义结构”而非”单一语法”来做。
2.2 合订本切块:产品名缺失 + 父子跨产品串味(一个 trace 驱动的深挖)
还是这个保险知识库,一条线上 query「80 岁的人可以投**国寿鑫缘宝终身寿险(万能型)(乐鑫版)**这款保险吗?」暴露了切块更深的两个结构性缺陷。症状很反直觉:答案(投保范围”七十五周岁以下”)明明在已入库的文档里,系统却退回”通用常识 + 网络来源”作答。从 Langfuse trace 一层层剥:
- 先排除”兜底逻辑丢证据”(那是另一刀,CRAG 把 AMBIGUOUS 强制翻成 0-chunk 兜底,已单独修)。修完后答案不再瞎编,但置信度仍 LOW、来源仍是 web——说明 KB 里那个答案块根本没被排上来。
- 再排除”reranker 假分”:trace 里 rerank top1=0.4636 看着不低,但直接
curl复现发现线上还在用 gte-rerank(v1)→ 403 AccessDenied,CrossEncoderReranker静默降级成关键词排序——那 0.4636 是关键词重叠分,不是语义分。切到 gte-rerank-v2 后,进库捞出真正的答案块重打分,只有 0.169——这才看清真正的两个切块根因。
根因一:产品名只活在 metadata.chapter,没进 content。 进 MySQL 看答案块(kb_chunk id 6797):content 是「## 第二条 投保范围\n\n凡出生…七十五周岁以下…」,产品名一个字都没有;产品名在 metadata.chapter「国寿鑫缘宝…乐鑫版条款 > 第二条 投保范围」里——可那是展示用的,embedding/BM25/rerank 看的全是 content。query 被产品专名主导,答案块语义面却没有产品名,于是 0.169,被一张现金价值大表格(0.382)和第一条套话(0.305)挤掉,“七十五周岁”只能从 web 捞回——这就是”引用网络而非知识库”的真相。
根子在 assembleChunks:标题前缀来自 pendingHeadingPrefix(只装”上次 flush 之后新出现的标题”),产品级 H1 被它后面第一个 chunk 消费后就清空了,于是第二条、第三条…的 content 全丢了产品名。结构信息(heading 层级)提取到了,却只写进了展示用的 metadata,没接回检索链路。
根因二:父子映射跨产品串味。 这篇 PDF 其实是合订本——个人保险基本条款 + 国寿鑫富宝年金 + 国寿鑫缘宝乐鑫版三个产品。chunkWithParents 纯按 size ≤ 2000 贪心打包相邻子块,完全不看子文档边界:乐鑫版「第二条投保范围(75 周岁)」子块的 parent_chunk_id 指向「条款目录」,国寿鑫富宝「第二条投保范围(70 周岁)」指向另一份的「第十六条释义」,一个父块横跨两个产品——70/75 串味,命中后展开成”目录表 + 别家条款”的大杂烩。
两刀修复,本质都是”把结构信息接回检索链路”:
- P0(产品名进 content):把标题前缀来源从
pendingHeadingPrefix改为整条headingStack全栈渲染(composeWithHeadingPath),每个子块都自带从产品名 H1 到当前条款的完整路径。实测答案块 rerank 分 0.169 → 0.415(2.6×),反超现金价值表。 - P1(父块按子文档边界硬断开):
chunkWithParents打包判据加一条——chapter根段(首个>前的产品标题)一变即强制起新父块(chapterRoot)。不同产品不再混进同一父块;单产品文档根段恒定,行为不变零回归。
验证是端到端跑通的:重建后端镜像 + reprocess 重入库(kb35 父块 13→16),同一 query 复跑——isFallback 从 true 变 false,4 条来源全部是 kb35 向量、零 web,答案正确给出”被保险人 75 周岁以下 → 80 岁不符合,无法投保”。而”七十五周岁”恰好是靠 P1 进来的:乐鑫版第一条命中后展开的父块,现在干净地包含了同产品的第二条投保范围。
一个被反复讨论清楚的设计取舍:P1 要不要改成”在 size 上限内尽量覆盖更多根标题(产品)“?否决了——父块目标不是”装满”而是”给命中子块干净的同主题上下文”。召回排序在子块上做(P0 后子块已自带面包屑),父块只是 LLM 上下文,且 compress 的 focus-truncate 会把 >800 字的父块以命中点为中心截到 ~800 字,装得再大模型也看不到更多;而跨产品装满会把刚修好的 70/75 串味 bug 装回来。风险不对称:切碎只是上下文略不满(focus-truncate 兜住),串味是答错产品。 所以产品边界做硬约束;真要治”普通多 H1 文档切太碎”,正解是”同根内 MIN_PARENT 合并”而非跨根装满。
这个坑串起 2.1 是同一个道理:切块缺陷不报错、只悄悄拉低召回,得靠 trace 观测信号 + 直接查库比对才能挖出来;而修复的母题是——已经提取到的文档结构,必须同时喂给”检索面(content)“和”组织面(父块边界)“,只写进展示用的 metadata 等于白提取。
3. YAML Frontmatter + 正文头部元数据提取
- 解析
---包裹的 YAML frontmatter,提取 version/date/title 字段(支持中英文键名) - frontmatter 缺失时,扫描正文前 500 字用正则补提版本号和日期(覆盖非 Markdown 文档)
- 文档级元数据注入所有 chunk 的
docVersion和effectiveDate字段,支持下游时效性过滤
4. 父子双层切块(TextChunker.chunkWithParents + ParentChunkResolver)
- 切块阶段:先按标准逻辑切出子块(TARGET=400字),再将相邻子块合并为父块(TARGET=1500字,MAX=2000字)。每个子块记录
parentIndex。父块装配尊重子文档边界:除 size 上限外,子块chapter根段(产品/文档标题)一变即强制起新父块,避免合订本里跨产品打包导致父子串味(见 2.2 踩坑) - 持久化阶段(
DocumentProcessTask):先持久化父块到kb_chunk表获取 DB 自增 ID(父块不写入 Milvus、不做 embedding),再将parentChunkId写入子块。子块写入 Milvus 做向量检索 - 检索阶段(
ParentChunkResolver.resolve):子块命中后,批量查kb_chunk获取parent_chunk_id,再批量查父块内容。用父块内容替换子块内容(保留子块的 score/source 等检索元数据)。多个子块指向同一父块时只保留rerankScore最高的那个(LinkedHashMap去重) - 向后兼容:没有 parent 的历史数据(升级前入库的 chunk)保留原样直接返回
5. SHA-256 增量索引(DocumentProcessTask.processIncremental)
diff 算法:
old = 库里现存 chunk(按 (kb_id, content_hash) 索引)
new = 重新切分后的 chunk
for each n ∈ new:
if exists o ∈ old with o.content_hash == n.content_hash:
keep —— 复用 o.vector_id,仅更新 chunk_index / metadata
else:
add —— 生成新 vector_id,embedding 后写入 Milvus + kb_chunk
remaining old (未被 new 匹配) → delete —— Milvus 精准删除 + kb_chunk 删除plaintext关键设计:
- 多重映射(
Map<String, Deque<KbChunk>>):同一 hash 可能对应多个 chunk(重复段落),用 Deque 做一对多映射,FIFO 消费 - 删除顺序:先删 Milvus 再删 MySQL——删错可重建,反之残留 vector 会污染检索结果
- 无 hash 兼底:升级前入库的老 chunk 缺少 content_hash,会被当作”全部删除 + 全部新增”处理,等价于一次全量重建——首次升级后的预期行为
- 语义缓存联动:增量更新完成后调
kbVersionService.bump()自增 KB version,使引用此 KB 的语义缓存条目自动失效
6. 自动内容类型检测与标签提取
detectContentType():基于关键词匹配检测 procedure/warning/example/definition/general 五种类型,写入 Milvus metadata 的contentType字段,支持检索时按类型过滤extractTags():取所有非空 heading(长度 ≤20)+ contentType 作为自动标签,JSON 数组格式写入tags字段
量化结果(Result)#
- 表格完整性:TABLE/CODE block 原子保留,不再出现半张表送 LLM 的问题
- 章节定位:heading breadcrumb 让检索结果可以按章节过滤,“第三章”类查询不再召回错误章节
- 增量效率:200 页文档改 1 页,embedding 调用从约 200 次降到约 1 次(实际取决于变更 chunk 数量)
- 父子块兼顾:子块 400 字精准检索 + 父块 1500 字完整上下文,LLM 生成质量提升(通过 rerank 后 MRR 间接体现)
代码锚点#
| 类/方法 | 路径 | 职责 |
|---|---|---|
TextChunker | service/knowledge/TextChunker.java | Markdown-Aware 切分器主类:Block 解析 + chunk 装配 + SHA-256 hash |
TextChunker.chunk() | 同上 | 标准切分入口:预处理 → frontmatter 解析 → Block 解析 → chunk 装配 → 元数据注入 |
TextChunker.chunkWithParents() | 同上 | 双层切块入口:先切子块(400字)再合并父块(1500字);父块按 chapterRoot 子文档边界硬断开(见 2.2) |
TextChunker.chapterRoot() | 同上 | 取 chapter 面包屑根段(产品/文档标题),用于父块装配判定子文档边界 |
TextChunker.parseBlocks() | 同上 | Block 类型识别:5 种 BlockType(HEADING/PARAGRAPH/TABLE/CODE/IMAGE) |
TextChunker.assembleChunks() | 同上 | Heading 栈维护 + Block 差异化装配 + buffer 合并/flush 策略 |
TextChunker.composeWithHeadingPath() | 同上 | 整条 heading 栈渲染为 markdown 标题前缀注入子块 content(产品名进检索面,见 2.2) |
TextChunker.splitBySentence() | 同上 | 超长段落按中文句末标点切分,保留 50 字重叠 |
TextChunker.parseFrontmatter() | 同上 | YAML frontmatter 解析:version/date/title 字段提取 |
TextChunker.buildBreadcrumb() | 同上 | heading 栈拼接为 "章 > 节 > 小节" 面包屑 |
TextChunker.sha256Hex() | 同上 | 内容 hash 计算,用于增量索引 diff |
TextChunker.detectContentType() | 同上 | 关键词匹配检测 5 种内容类型 |
TextChunker.extractTags() | 同上 | 从 heading 栈 + contentType 自动提取标签(JSON 数组) |
DocumentExtractor | service/knowledge/DocumentExtractor.java | 多格式文档文本提取器:MinerU 优先 + PDFBox/POI 降级 |
DocumentExtractor.extract(Path, String, String) | 同上 | 本地文件提取路由:MinerU-only 格式 / PDF(MinerU+PDFBox 兜底) / DOCX/TXT |
DocumentExtractor.extractUrl() | 同上 | 网页 URL 解析入口(MinerU-HTML) |
MinerUClient | service/knowledge/MinerUClient.java | MinerU 云端 API 客户端:文件上传 + URL 提交 + 轮询 + zip 解压 |
MinerUClient.parseFile() | 同上 | 本地文件解析三步:申请上传链接 → PUT 上传 → 轮询结果 |
MinerUClient.extractMarkdownFromZip() | 同上 | 从 zip 抽取 full.md(优先)或首个 .md 文件 |
ParentChunkResolver.resolve() | service/rag/ParentChunkResolver.java | 检索阶段:子块命中 → 批量查父块 → 内容替换 + 同父去重 |
DocumentProcessTask.process() | service/impl/DocumentProcessTask.java | 全量处理流水线:清空旧 chunk → 双层切分 → 父块持久化 → 子块 embedding+Milvus |
DocumentProcessTask.processIncremental() | 同上 | 增量处理流水线:content_hash diff → 复用/新增/删除 |
DocumentProcessTask.batchEmbed() | 同上 | 分批向量化(batch size=10,适配 DashScope 单批上限) |
KbChunk | entity/KbChunk.java | 切片实体:content + contentHash + vectorId + parentChunkId + metadata + tags |
量化数据#
| 指标 | 数值 | 基线 | 来源 |
|---|---|---|---|
| 子块目标大小 TARGET_CHUNK_SIZE | 400 字 | — | TextChunker 常量 |
| 子块上限 MAX_CHUNK_SIZE | 600 字 | — | TextChunker 常量 |
| 父块目标大小 PARENT_CHUNK_SIZE | 1500 字 | — | TextChunker 常量 |
| 父块上限 PARENT_MAX_CHUNK_SIZE | 2000 字 | — | TextChunker 常量 |
| 句子切分重叠 OVERLAP_SIZE | 50 字 | — | TextChunker 常量 |
| 最小 chunk MIN_CHUNK_SIZE | 20 字 | — | TextChunker 常量 |
| Embedding 维度 | 1024d | — | text-embedding-v3 (DashScope) |
| Embedding 单批上限 | 10 | — | DocumentProcessTask.EMBED_BATCH_SIZE |
| 增量更新 embedding 调用 | ~1 次(仅变更 chunk) | ~200 次(全量重建) | SHA-256 diff |
| Block 类型数 | 5 种 | — | HEADING/PARAGRAPH/TABLE/CODE/IMAGE |
三、追问应对#
面试官想听到的信号#
- 理解切块不是”调个参数”而是一个涉及解析、结构识别、元数据、索引策略的系统性工程
- 能讲清楚为什么表格/代码必须原子保留——不是”好看”而是切碎了向量语义就废了
- 知道父子块的设计动机——检索和生成两个阶段的最优 chunk size 不同,父子块是解耦的标准方案
- 增量索引不是”有没有”的问题,而是知道 content hash diff 的具体实现和多重映射的边界处理
- 了解 Contextual Retrieval 等前沿方案,能评价 ROI 并讲出为什么当前没做(或下一步要做)
- 有格式分级路由意识——不同文件格式的解析能力不同,需要降级策略而不是 one-size-fits-all
追问预判与应答#
Q1(通用): 你用的切块策略和 LangChain 的 RecursiveCharacterTextSplitter 有什么区别?
A: RecursiveCharacterTextSplitter 是按分隔符层级递归——先双换行、再单换行、再句号、再空格,本质上还是”找断点切”,不识别内容类型。我的 TextChunker 先做 Block 类型识别——把文本解析为 HEADING/PARAGRAPH/TABLE/CODE/IMAGE 五种 Block,然后按类型差异化处理:TABLE 和 CODE 原子保留不切碎,HEADING 维护栈结构注入面包屑,PARAGRAPH 才走常规的合并+切分逻辑。这比 RecursiveCharacterTextSplitter 多了一层结构感知,但比 SemanticChunker 轻很多——不需要逐句调 embedding API。如果输入不是 Markdown(比如纯 DOCX 文本),Block 解析识别不到任何结构化元素,自动退化为朴素的段落合并+句子切分,行为等价于 RecursiveCharacterTextSplitter。
Q2(通用): Contextual Retrieval 你有没有做?为什么?
A: 目前没做完整的 Contextual Retrieval,但做了轻量版替代——Heading 前缀注入。每个 chunk 的开头会拼上它所属的 heading 行,比如
## 产品规格\n\n该参数不得低于 85%,这样 embedding 里就包含了章节上下文信号。这比 Anthropic 原版方案(用小模型给每个 chunk 生成一句上下文描述)成本低很多——零 LLM 调用。但效果也弱一些:heading 前缀只有章节标题信息,没有内容摘要。下一步如果要做,计划用最便宜的小模型(如 qwen-turbo)+ prompt caching 批量生成 chunk 上下文,叠加到 heading 前缀之上。Anthropic 的数据是检索失败率 -49%,这个 ROI 非常可观。
Q3(项目): 父子块的 1500 字和 400 字是怎么定的?
A: 400 字是 embedding 模型的甜区——text-embedding-v3 虽然 max_tokens=2048,但实证表明 200-500 字区间的向量质量最高,超出后语义被稀释。1500 字是 LLM 的上下文利用甜区——结合 Lost in the Middle 现象,给 LLM 喂 5-6 条 chunk、每条 1500 字,总量 ~3000-4000 token,落在注意力集中区间。父块上限 2000 字是防止极端情况下一个父块太长挤占其他 chunk 的 token 预算。这些值不是理论推导的,是在我们的文档集上调参测出来的——试过 300/1000(父块上下文不够)和 500/2000(子块精度下降、父块太长),400/1500 是综合 MRR 和生成质量的最优点。
Q4(项目): 增量索引遇到 content_hash 冲突怎么办?
A: 这里的 hash 是完整 SHA-256(64 字符 hex),碰撞概率理论上是 2^-256 ——可以忽略。但有一个真实的边界情况:同一文档里有重复段落(比如每章开头的标准免责声明)。同一 hash 可能对应多个 chunk,所以我用
Map<String, Deque<KbChunk>>做多重映射,不是简单的Map<String, KbChunk>。新 chunk 列表匹配时 FIFO 消费 Deque,确保每个旧 chunk 最多被复用一次。如果新文档删了一个重复段落,Deque 里多出来的旧 chunk 自然落入”待删除”集合,被从 Milvus 和 MySQL 精准清理。
Q5(项目): MinerU 挂了怎么办?
A: 分格式处理。PDF 有降级路径——MinerU 失败后自动回退到 PDFBox 纯文本提取。质量会下降(表格变乱序文字、多栏识别丢失),但至少不阻塞入库流程。PPT 和图片没有等价的 Java 替代方案,MinerU 挂了直接抛错、标记
failed状态——这是有意的 fail-fast,因为 PPT/图片没有纯文本提取器能用,静默入库一堆空内容比报错更危险。另外 MinerU 有动态开关parser.mineru.enabled,如果云端持续故障,运维可以热关闭 MinerU 让所有 PDF 走 PDFBox 降级,不需要重启服务。
Q6(通用): 你怎么看 Late Chunking?
A: Late Chunking 是 Jina 2024 年提的方案——先用 long-context embedding 模型对全文做一次编码(利用完整上下文的交叉注意力),然后按切块边界把 token 级向量做 mean pooling 得到 chunk 向量。好处是每个 chunk 的向量保留了跨 chunk 的上下文信息,不像传统方案每个 chunk 独立编码。但有两个限制:一是需要 long-context embedding 模型(不是所有模型都支持),二是全文编码一次的计算成本远高于逐 chunk 编码(尤其长文档)。我们目前用的 text-embedding-v3 max_tokens 是 2048,不支持 long-context 编码,所以用 heading 前缀 + 父子块来弥补上下文缺失。如果未来换用支持 8K+ 的 embedding 模型,Late Chunking 值得尝试。
Q7(项目): 你的 Block 识别全靠正则,误识别怎么办?
A: 确实是靠正则。
| ... |识别表格、识别代码围栏、`# ` 识别 heading、`![]()` 识别图片,另外还认 `<table>...</table>` 的 HTML 表格(MinerU 解析 PDF 时表格会出 HTML 而非管道表)。误识别的主要场景是:正文里出现类似表格语法的竖线分隔文本(比如 `选项 A | 选项 B` 这种行内分隔)。我的正则要求行首行尾都有竖线(`^\\s*\\|.*\\|\\s*$`),所以行内分隔符不会误触发。代码围栏也类似——必须是行首才触发,行内的反引号不会误判。这里我反而吃过”漏识别”的亏——HTML 表格一开始没被任何正则覆盖,被当成普通段落切碎了,是查库比对(含<table>的切片contentType不是 table)才发现的,补了 HTML 表格分支后修掉。所以我的体会是:正则方案不怕误判(约束写紧就行),怕的是漏识别——上游解析格式一变就有盲区,得靠观测信号兜底。极端情况下如果有格式不规范的 Markdown(比如 heading 前没空行)也可能漏识别,但 MinerU 输出的 Markdown 格式是标准化的,实际很低。如果真要做到零误判零漏判,就得上 AST 级别的 Markdown parser(比如 commonmark-java),但工程成本高且这里的收益不明显。
Q8(项目): 为什么不用语义切块(SemanticChunker)?
A: 两个原因。第一,成本——SemanticChunker 需要逐句计算 embedding 相似度,一篇 200 页的文档可能有几千个句子,就是几千次 embedding 调用。我们用的是 DashScope API 按 token 计费,光切块阶段就要花掉一大笔 API 费用,而且延迟也不可接受(单句 embedding ~100ms × 3000 句 = 5 分钟)。第二,NAACL 2025 的实证研究发现固定 200 词切块在部分 benchmark 上和语义切块持平——说明切块策略的收益边际递减,复杂方案不一定比简单方案好多少。我的方案走了一条中间路线:比固定切块和递归切块多了结构感知(Block 类型识别 + heading 栈),但比语义切块轻得多(零 embedding 调用)。如果要上语义切块,建议先跑 eval 证明收益,不要想当然地认为”复杂 = 更好”。
Q9(项目): 你说”产品名没进 content 导致排不进 top”——为什么把面包屑写进了 metadata 却没写进 content?这不是低级失误吗?
A: 这恰恰是个容易踩的设计陷阱。我的 chunker 一直在维护两套结构:
headingStack渲染成chapter面包屑写进 metadata(展示用、可按章节过滤);另一套pendingHeadingPrefix把标题拼进 content 当语义上下文。问题在第二套的语义是”自上次 flush 以来新出现的标题”——所以一个父级标题(合订本的产品名 H1)只会贴到它后面第一个 chunk,被消费后就清空了,后续同级条款(第二条、第三条…)的 content 就只剩自己那行小标题,丢了产品名。而 metadata 那套headingStack是跨 flush 保留全路径的,所以”产品名只在 metadata、不在 content”。表面看 content 里”有标题”(## 第二条),不仔细对比发现不了缺的是祖先标题。修复就是把 content 前缀的来源也改成全栈headingStack渲染(composeWithHeadingPath),和 metadata 对齐。教训是:文档结构提取出来后,要同时喂给”检索面(content)“和”组织面(父块边界)“,只喂展示用的 metadata 等于白提取——而且这种缺陷不报错,是 trace 里”答案来自 web 而非 KB” + 直接查库 + curl 复测 rerank 分(0.169→0.415)才坐实的。
Q10(项目): 父块为什么要在产品边界”硬断开”?在 size 上限内多塞几个产品的条款,父块更满、上下文更全,不是更好吗?
A: 这个我专门权衡过,结论是不能跨产品装满,原因有三。第一,父块的目标不是”装满”而是”给命中子块干净的同主题上下文”——召回排序是在子块上做的(子块已自带完整面包屑能独立命中),父块只是命中后喂给 LLM 的上下文。第二,
compress有 focus-truncate:父块只要超过 800 字,就会以命中点为中心截到 ~800 字再喂模型,父块装得再大,模型实际看到的并不会更多,只是命中点周围多塞了无关条款(稀释)。第三也是最关键的——跨产品装满会把刚修好的 correctness bug 装回来:合订本里 70 周岁那款和 75 周岁那款前后相接且都很短,一旦塞进同一父块,命中一款、上下文里却混着另一款的年龄,答错产品。所以这是个风险不对称的选择:切碎只是”上下文略不满”(还被 focus-truncate 兜住),串味是”事实答错”。两害相权,产品边界做硬约束。如果普通的多 H1 文档真被切得太碎,正解也不是跨根装满,而是”同根内 MIN_PARENT 合并”——既消除碎小父块,又绝不跨产品。
反击引导#
讲完知识工程优化后,可以自然引到以下强项故事:
- “切块之后就是检索和排序——我们做了向量+BM25+Web 三路召回 + RRF 融合 + Cross-Encoder 精排” → 引到 主题 4 · Rerank 和截断优化
- “父子块只解决了单文档内的上下文,跨文档的多跳检索靠 Agentic 循环” → 引到 Agentic RAG 核心设计
- “增量索引省了 embedding 成本,但整个入库流程还有异步处理、状态机、失败重试等工程化问题” → 引到 工程化实践
- “切块质量最终要靠评测来验证——我们建了一套 Recall/MRR/忠实度的离线评测体系” → 引到 可信生成与评测