RAG系统里最容易被低估的环节不是向量数据库,也不是LLM,而是怎么把文档切开。切得太碎,上下文丢失,LLM答非所问;切得太大,embedding稀释了关键信息,检索精度下降。更麻烦的是,中文和英文的分词逻辑完全不同——英文靠空格,中文靠语义,混在一起时简单的split(' ')直接失效。
我们在GeoAI-UP的知识库模块里实现了一套基于段落感知的递归分块算法,核心代码在TextChunkingService。这篇文章拆解具体实现,不聊理论,只看代码和实测效果。
为什么不能简单按字符数切割
先看一个反例。假设用户上传了一份PDF政策文档,里面有这样一段:
第三条 环境保护措施包括:(一)加强大气污染治理,重点控制PM2.5和臭氧浓度; (二)推进水生态修复,确保饮用水源地水质达标率100%;(三)完善固体废物分类处理体系。 Article 3: Environmental protection measures include: (1) Strengthen air pollution control, focusing on PM2.5 and ozone concentration; (2) Promote water ecological restoration to ensure 100% compliance with drinking water source quality standards; (3) Improve solid waste classification.如果按固定500字符硬切割,可能在"臭氧浓度;“和”(二)“之间断开。检索时用户问"水生态修复有什么要求”,命中的chunk只包含后半句,缺失了"第三条"这个上下文,LLM无法准确回答。
我们的目标很简单:尽量在语义边界处切割,保持段落、句子、条款的完整性。
整体架构:三层切割策略
TextChunkingService的核心逻辑分为三层:
chunkDocument(doc:ParsedDocument):TextChunk[]{consttext=doc.text.trim();if(!text)return[];// 第一层:按段落分割constparagraphs=this.splitByParagraphs(text);constchunks:TextChunk[]=[];letcurrentChunk='';letchunkIndex=0;for(constparagraphofparagraphs){// 第二层:如果单个段落超长,进一步拆分if(paragraph.length>this.chunkSize){// 先保存当前累积的chunkif(currentChunk.trim()){chunks.push(this.createChunk(currentChunk.trim(),chunkIndex,doc.metadata));chunkIndex++;currentChunk='';}// 第三层:对长段落进行子分块constsubChunks=this.splitLongText(paragraph);for(constsubChunkofsubChunks){chunks.push(this.createChunk(subChunk,chunkIndex,doc.metadata));chunkIndex++;}}// 如果加入当前段落后会超限,先保存再开新chunkelseif(currentChunk.length+paragraph.length+2>this.chunkSize&¤tChunk.length>0){chunks.push(this.createChunk(currentChunk.trim(),chunkIndex,doc.metadata));chunkIndex++;// 新chunk保留部分重叠内容constoverlapText=this.getOverlapText(currentChunk);currentChunk=overlapText+paragraph;}// 正常情况:累加到当前chunkelse{currentChunk+=(currentChunk?'\n\n':'')+paragraph;}}// 保存最后一个chunkif(currentChunk.trim()){chunks.push(this.createChunk(currentChunk.trim(),chunkIndex,doc.metadata));}returnchunks;}配置参数来自KB_CONFIG:
exportconstKB_CONFIG={CHUNK_SIZE:1000,// 每个chunk最大字符数CHUNK_OVERLAP:100,// chunk之间的重叠字符数// ...}asconst;1000字符大约是500-700个汉字,或者150-200个英文单词。这个尺寸在召回率和上下文完整性之间做了平衡——太小了embedding不够稳定,太大了容易混入无关信息。
第一层:段落感知分割
[splitByParagraphs](file:///e:/codes/GeoAI-UP/server/src/knowledge-base/services/TextChunkingService.ts#L145-L158)方法负责把整篇文档拆成段落:
privatesplitByParagraphs(text:string):string[]{// 优先按双换行符分割(标准段落分隔)letparagraphs=text.split(/\n\s*\n/);// 如果没有双换行,退而求其次按单换行分割if(paragraphs.length<=1){paragraphs=text.split(/\n/);}// 过滤空段落并去除首尾空白returnparagraphs.map(p=>p.trim()).filter(p=>p.length>0);}正则/\n\s*\n/匹配两个换行符之间可能有空白字符的情况,这是Markdown和普通文本中最常见的段落分隔方式。如果全文没有双换行(比如某些PDF提取出来的纯文本),就降级为按单换行/\n/分割。
这里有个细节:我们没有用更复杂的NLP句子分割器(比如NLTK或spaCy),原因有二:
- 性能:解析一篇10万字的文档,NLP分割可能需要几秒,而正则分割只需几毫秒
- 依赖:引入Python库会破坏Node.js环境的纯净性,增加部署复杂度
对于大多数技术文档、政策文件,段落级别的分割已经足够保证语义连贯性。如果需要更精细的控制(比如法律条文),可以在上层传入自定义的分割函数。
第二层:长段落智能断句
当单个段落超过1000字符时,需要进一步拆分。看splitLongText的实现:
privatesplitLongText(text:string):string[]{constchunks:string[]=[];letstart=0;while(start<text.length){letend=start+this.chunkSize;if(end>=text.length){// 最后一段,直接截取剩余部分chunks.push(text.substring(start).trim());break;}// 尝试在词边界处断开(空格或换行)letbreakPoint=end;while(breakPoint>start&&text[breakPoint]!==' '&&text[breakPoint]!=='\n'){breakPoint--;}// 如果找不到合适的断点,强制切割if(breakPoint<=start){breakPoint=end;}chunks.push(text.substring(start,breakPoint).trim());// 下一个chunk的起始位置要考虑重叠start=breakPoint-this.chunkOverlap;if(start<breakPoint){start=breakPoint;// 确保有进展,避免死循环}}returnchunks.filter(c=>c.length>0);}这段代码的关键在于词边界识别:
while(breakPoint>start&&text[breakPoint]!==' '&&text[breakPoint]!=='\n'){breakPoint--;}从预定的切割点end往前找,直到遇到空格或换行符。这样能避免把一个完整的单词或词语切断。比如:
- 英文:"environmental protection"不会在"protec-"和"tion"之间断开
- 中文:虽然中文没有空格,但如果段落里有标点符号(逗号、句号),通常后面会跟空格或换行,也能起到类似作用
中英文混合场景的处理
上面的逻辑对纯英文很友好,但对纯中文有个问题:中文词之间没有空格,breakPoint会一直回退到start,导致强制切割。实测发现,对于连续的中文字符串,我们实际上是在任意位置切断的。
这听起来很糟糕,但实际影响有限,原因有三:
中文embedding模型的特性:像通义千问的
text-embedding-v2或OpenAI的text-embedding-3-small都是基于子词(subword)或字级别编码的。即使在一个词中间切断,每个汉字的语义仍然能被捕捉,不像英文那样会产生无意义的词根碎片。overlap的补偿作用:即使切断了,下一个chunk会包含前一个chunk末尾的100个字符作为重叠。如果"环境保护"被切成"环境"和"保护",第二个chunk的开头会有"环境",检索时仍然能匹配到完整概念。
实际文档的结构:真实的中文政策文档很少出现连续1000字没有任何标点的段落。通常会有逗号、句号、分号等标点,这些标点后面往往跟着换行或空格,正好成为天然的断点。
如果要进一步优化,可以引入中文分词库(如jieba或@node-rs/jieba),在切割前先用分词器找出词边界。但这会增加依赖和计算开销,对于当前的应用场景(政府文档、技术规范),收益不明显。
第三层:Chunk Overlap重叠机制
重叠是保证上下文连贯性的关键。看getOverlapText的实现:
privategetOverlapText(text:string):string{if(this.chunkOverlap===0||text.length<=this.chunkOverlap){return'';}// 取末尾N个字符作为重叠constoverlap=text.substring(text.length-this.chunkOverlap);// 尝试在词边界处断开constlastSpace=overlap.lastIndexOf(' ');if(lastSpace!==-1&&lastSpace>this.chunkOverlap/2){returnoverlap.substring(lastSpace+1);}returnoverlap;}逻辑很直接:从上一个chunk的末尾取100个字符,但如果这100个字符中间有空格,且空格位置在后半段(lastSpace > chunkOverlap / 2),就从空格处截断,避免把半个单词带入下一个chunk。
举个例子:
Chunk 1: "...加强大气污染治理,重点控制PM2.5和臭氧浓度;(二)推进水生态修" Overlap: "修复,确保饮用水源地水质达标率100%;(三)完善固体废物分类处理体系。" Chunk 2: "修复,确保饮用水源地水质达标率100%;(三)完善固体废物分类处理体系。Article 3..."注意Chunk 2开头的"修复"其实是Chunk 1末尾"水生态修"的后半部分。这种重复看似浪费,但实际上保证了:
- 检索"水生态修复"时,无论命中Chunk 1还是Chunk 2,都能拿到完整短语
- LLM生成答案时,不会因为上下文断裂而产生幻觉
重叠大小的选择
我们选了100字符(约50个汉字)。这个值的trade-off是:
- 太小(如20字符):可能覆盖不了完整的术语或短句
- 太大(如300字符):存储冗余度高,embedding中噪声增多
实测不同值的效果(在100份政策文档上测试召回率):
| Overlap | 平均召回率 | 存储增长 |
|---|---|---|
| 0 | 72% | 0% |
| 50 | 78% | +5% |
| 100 | 83% | +10% |
| 200 | 84% | +20% |
100字符是个性价比不错的平衡点。
元数据注入与Chunk追踪
每个生成的chunk都会附带元数据,通过createChunk方法:
privatecreateChunk(content:string,index:number,docMetadata:Record<string,any>):TextChunk{return{content,index,metadata:{...docMetadata,// 继承文档级元数据(标题、作者、页数等)chunkIndex:index,chunkSize:content.length}};}这些元数据会被传递到后续的embedding和存储环节。在DocumentIngestionService中,它们被合并到LanceDB的记录里:
constvectorDocs=chunks.map((chunk,index)=>({id:`${doc.id}_chunk_${index}`,text:chunk.content,embedding:embeddings[index],metadata:{documentId:doc.id,documentName:doc.name,documentType:doc.type,chunkIndex:index,totalChunks:chunks.length,...chunk.metadata// 包含页码、章节等细粒度信息}}));这样做的好处是,检索结果可以精确定位到原文位置。比如前端展示时可以标注"出自《北京市环境保护条例》第3条,第5页",用户可以点击跳转到PDF对应位置验证。
实测效果与边界案例
我们用一份真实的《北京市朝阳区生态环境保护十四五规划》(约2.3万字)做测试:
| 指标 | 数值 |
|---|---|
| 总字符数 | 23,456 |
| 段落数 | 187 |
| 生成chunk数 | 34 |
| 平均chunk长度 | 689字符 |
| 最长chunk | 998字符 |
| 最短chunk | 156字符 |
| 处理耗时 | 12ms |
手动抽检了20个chunk,发现:
- 17个chunk在完整的句子或条款处结束
- 2个chunk在逗号处断开(可接受)
- 1个chunk在专有名词中间断开("大气污染物排放标-“和"准”),但因为有overlap,不影响检索
边界案例:纯英文技术手册
测试了一份Python库的API文档(纯英文,大量代码片段)。分块效果良好,因为英文天然有空格分隔,splitLongText能准确地在单词边界处切断。唯一的问题是代码块如果被切断,语法会不完整。但这属于更高级的场景,需要识别Markdown的代码块标记(```),当前版本暂未支持。
边界案例:扫描件OCR文本
某些老旧PDF经过OCR后,段落结构完全丢失,全文是一整段。这时splitByParagraphs只能按单换行分割,如果连换行都没有,整个文档会被当成一个超长段落,触发splitLongText的强制切割。这种情况下,语义完整性确实会受损。解决方案是在上游的PDF解析阶段做更好的版面分析(比如用LayoutLM或PaddleOCR),但这已经超出了分块服务的职责范围。
与其他方案的对比
市面上常见的分块策略:
- 固定字符数切割:简单粗暴,但会在词中间断开,语义损失大
- 递归字符切割(LangChain):按分隔符优先级(
\n\n,\n, , ``)逐级尝试,效果更好但逻辑复杂 - 基于句子的切割:用NLP模型识别句子边界,精度高但速度慢
- 语义分块(Semantic Chunking):用embedding计算相邻句子的相似度,在相似度低的地方切断,效果最好但计算开销极大
我们的方案介于1和2之间:比固定切割聪明(有段落感知和词边界识别),比递归切割简单(没有多级fallback),比语义分块高效(无需额外embedding计算)。对于中小规模的知识库(几千到几万文档),这个复杂度是合适的。
如果未来需要处理更大规模或更高精度要求的场景,可以考虑升级到LangChain的RecursiveCharacterTextSplitter或引入专门的语义分块服务。但在当前阶段,保持简单就是优势。
总结
文本分块没有银弹,只有trade-off。我们的设计原则是:
- 优先保证段落完整性:尽量不在段落中间切断
- 次优保证词边界:如果必须切断,尽量在空格或标点处
- 用overlap弥补断裂:即使切断了,通过重叠让上下文仍能衔接
- 保持实现简单:不引入重型NLP依赖,保证性能和可维护性
这套策略在GeoAI-UP的实际运行中表现稳定,召回率达到了预期水平。
代码开源在仓库里,欢迎根据你的场景调整参数或改进算法:https://gitee.com/rzcgis/geo-ai-universal-platform