ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

语义分块实战:从固定窗口到语义边界,提升RAG检索精度

语义分块实战:从固定窗口到语义边界,提升RAG检索精度

RAG(Retrieval-Augmented Generation,检索增强生成)跑起来不难,难的是把检索精度做上去。我们之前上线过一版知识库问答,召回质量一直卡在七八十分上不去,后来排查来排查去,问题居然不是出在Embedding模型,也不是向量检索的算法参数,而是最容易被忽略的分块(Chunking)环节。用固定长度硬切文本,语义被切断了,后面的生成效果自然跟着崩。这篇文章就从这个角度切入,聊聊语义分块到底怎么落地,怎么用语义边界替代固定窗口,把RAG的检索精度真正拉上去。

文章适合正在做RAG项目、被召回质量困扰的开发者,也适合刚入门、想搞懂分块原理和实操路径的读者。我会从固定分块的缺陷、语义边界的判定逻辑、具体实现路线,到最后的指标验证和调参经验,完整梳理一遍,过程中会给出可直接用的代码和关键参数的设置思路。

1. 固定分块的“硬伤”:为什么检索结果总是七零八落

1.1 从一次“找不到答案”的案例说起

先说我遇到过的一个真实问题。知识库里有篇关于设备运维的文档,其中一段讲“振动检测与轴承故障的关联性”,文本不是特别长,大概一千字左右。用户提问:“轴承出现磨损初期,振动频谱会有什么特征?”系统检索到的Top结果里,前五条全是各种无关片段,要么是操作步骤列表,要么是别的设备的参数说明,真正包含“磨损初期频谱特征”的段落,排在第七八位之后。下游生成模型拿到的上下文里没有答案,回答自然就是一句“知识库中未找到相关内容”。

这种问题在固定分块方案下几乎是必然的。固定分块一般按字符数或token数切,比如每300个token一块,块与块之间有少量重叠。文本一长,一个完整的知识点很容易被拦腰截断:前半段讲“正常状态下的频谱基线”,后半段讲“磨损初期的频谱偏移”,但这两个信息分散在两个不同的块里,语义连续性被打散。检索时用户Query向量和每一块的向量做相似度计算,匹配到的往往是包含关键词最多的那一小块——但这一小块可能恰恰缺了关键的上下文信息,导致召回的片段虽然“沾边”,却答非所问。

1.2 碎片化背后:向量相似度不等于语义完整性

固定分块的另一个隐蔽问题,是它让向量检索的“得分”失去参考意义。Embedding模型生成的向量是基于局部文本内容的语义表征。当输入本身是不完整的片段,向量只能表征这个片段内部的局部语义,无法感知它隶属于哪个更大的话题或知识单元。于是会出现两种情况:

一是检索得分虚高,但内容残缺。某一段是文档中的一句话,恰好包含“轴承”“频谱”这些关键词,它在语义上和Query很接近,向量相似度高,排在前面。但它的上下文中缺少前置逻辑,模型读不出来龙去脉,生成时只能瞎编或者回答“不知道”。

二是检索分散在多个片段中,得分都不高不低。正确答案分散在两块甚至三块里,单独拿哪一块都和Query相似度不够,最终排序靠后。固定分块的碎片化直接导致这两种情况高频出现,检索精度自然上不去。

1.3 为什么要用“语义”重新定义分块

要解决上面的问题,核心思路是让每一个分块都是一个尽可能完整的“语义单元”——一个话题、一个知识点、一个逻辑闭环。这样分出来的块,内部信息连贯,块与块之间有清晰的边界。检索的时候,Query匹配到的是完整话题,而不是某个被腰斩的片段;下游拿到的是充足的上下文,而不是残缺的只言片语。

这就是语义分块(Semantic Chunking)要解决的核心问题。它在两条路线上展开:一是自动判断文本哪里是语义边界、在哪里切分;二是切出来的块在语义上自洽,不依赖固定长度。听起来有点虚,但落地其实有迹可循,下面逐步拆解。

2. 语义边界判定:文本在哪里断开才合理

2.1 从句子到段落:逐层递进的切分思路

判断语义边界,基本思路是从小到大逐层看。句子是最小的语义单元,但一句话往往承载不了完整信息。段落通常表达一个相对完整的小主题,是较好的切分粒度。章节能表达更大的主题,但粒度太粗,不适合直接作为检索块。

实际操作中,我习惯先按自然段落粗切,再对超长段落做二次细分,对过短段落做合并。这样既保留了自然语言的分布特征,又能控制块的粒度在合理范围内。

举个具体例子,一篇文档里有这样两个相邻段落:

段落A:振动信号的频谱分析是基于FFT变换实现的。正常状态下,频谱能量主要集中在基频及少量低次谐波处,整体能量分布稳定。 段落B:当轴承出现早期磨损时,高频段会出现明显的能量抬升,并在特征频率附近形成边带。通过监测特定频带的能量变化,可以判断磨损发展趋势。

这两段一个讲正常基线,一个讲异常特征,各自话题相对独立,分开切成两个检索块是合理的。反过来,如果某个段落内部讲了“什么是特征频率”接着又讲“如何计算”,这两部分属于同一个知识点的不同侧面,就不该从中间断开。

2.2 段落不是万能解:什么时候不能用“自然段”切割

按自然段切有个前提——原文档本身结构良好。但真实语料里,大量文档的段落非常不规则:有的段落长达几千字,一个段落包含多个小话题;有的内容用列表、对话、代码块混排,自然段信息密度差异极大。

比如技术协议里常见“技术要求”这一节,可能包含十几条并列子项,每条只有几十个字。如果只按自然段切,整节可能被当成一个大块,检索时向量要同时表征十几个子项的语义,结果就是每个子项都“沾一点”,每个都不精确。这种情况下,需要递归地把大块拆小,把并列结构的子项独立成块。

还有一类情况是“列表型”内容。比如操作步骤的1、2、3,如果拆成三个块,彼此的语义关联很弱,但它们在流程上是紧密衔接的,如果用户问“整个安装流程”,拆开的块召回哪一个都不完整。合理的做法是把它们作为子块保留在同一个父块内,必要时做重叠切分,让父块覆盖完整流程、子块提供精确锚点。

2.3 语义完整性的三个特征:逻辑闭环、主题统一、指代可解

把“语义单元”具体化,我总结出三个可操作的判定特征:

第一是逻辑闭环。块内的信息在逻辑上能够独立承载一个知识点。比如“磨损会导致频谱高频段能量抬升”这一结论,它的前提、方法、结论都在块内,有头有尾。

第二是主题统一。块内讨论的是同一个话题,或者同一话题的紧密相关方面。判断方法是看每个块能不能用一个简短的名词短语概括,比如“早期磨损的频谱特征”或“正常基线建立方法”。如果一块需要两三个完全不同的话题才能概括,那这块的语义纯度一定不够,该切。

第三是指代可解。块内出现的“上述”“该方案”“如下”这类指代词,需要的索引对象应该在这块内能直接找到,或者是不需要外部解释的常识。如果检索块里出现“该方法在测试中得到验证”,而“该方法”的完整定义在前文,那下游模型拿到这个块也无法回答用户问题——它缺上下文。

这个三条判定标准,在实际落地时比抽象的“语义边界”好用得多。后续写代码判断切分点时,也可以围绕这三个特征设置规则。

3. 语义分块的实现路线:从Embedding到LLM的三种落地路径

3.1 路线一:基于Embedding相似度的主题漂移检测

最主流、也最容易上手的语义切分方法,是检测相邻句子之间的Embedding相似度突变。原理很简单:连续的句子里,如果两句话仍在讲同一个话题,它们的向量方向应该比较接近;如果话题切换了,相似度会有一个明显下降。这个下降点,就是语义边界。

具体实现时,先按句子粒度做分句,然后对每个句子生成Embedding向量,计算相邻句子之间的余弦相似度,设定一个阈值,相似度低于阈值的位置标为边界。为了更稳,通常不是只看相邻两句,而是取一个小滑动窗口,比如当前句和前3句的平均向量做相似度,减少单句噪声。

我用一个示例代码演示一下核心逻辑(基于Python和现成的sentence-transformer库构建):

import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh-v1.5") def split_sentences(text): # 简单分句,实际可接入更完善的分句工具 import re parts = re.split(r'([。!?;])', text) sents = [] for i in range(0, len(parts) - 1, 2): sents.append(parts[i] + parts[i+1]) if parts[-1]: sents.append(parts[-1]) return [s for s in sents if s.strip()] def semantic_chunk_by_similarity(text, threshold=0.72, window=3): sents = split_sentences(text) if len(sents) <= 1: return [text] vecs = model.encode(sents, normalize_embeddings=True) # 计算滑动窗口内的平均向量 boundaries = [] for i in range(window, len(sents)): context_vec = np.mean(vecs[max(0, i-window): i], axis=0) sim = np.dot(context_vec, vecs[i]) # 单位向量点积 if sim < threshold: boundaries.append(i) # 组装块 start = 0 chunks = [] for b in boundaries: chunk_text = "".join(sents[start:b]) if chunk_text.strip(): chunks.append(chunk_text.strip()) start = b chunks.append("".join(sents[start:]).strip()) return chunks

阈值的选择需要根据实际语料和Embedding模型来调,我一般在0.68-0.78之间试。阈值设太高,块切得太碎;设太低,边界检测不灵敏。判断标准就一条:切出来的块,用2.3里的三个特征去核对,看逻辑闭环和主题统一次数多不多。

这个方案的优点是纯本地就能跑,成本极低,单机CPU处理中等规模的文本也不成问题。缺点是相似度阈值对语料风格敏感,如果语料风格差异大(有的文本句子极短、有的句子超长),单一阈值容易在短句堆里产生大量误切。

3.2 路线二:利用大模型做语义段落识别(LLM辅助切分)

如果预算允许、对切分质量要求高,可以请LLM直接判断边界。做法是把整段文本发给大模型,让它输出建议的切分位置,并说明每个块的语义主题。这个方案理解语境的能力强,尤其适合法律文书、技术文档这类长文本。

给一个典型的Prompt参考:

请阅读以下文本,将其划分为若干语义完整的段落块。 要求: 1. 每个块需要表达一个相对独立、完整的信息单元; 2. 主题相近、逻辑紧密的内容不要拆开; 3. 块与块之间以[CHUNK]标记分割; 4. 不要修改原文内容,只负责标记切分位置。

LLM辅助切分的好处是,它能处理“指代”“条列”“隐含逻辑”这些复杂情况,不会机械地按句子长度或相似度判断。但问题也很直接:慢,贵。长文本动辄几千字,逐篇喂给LLM,对生产环境来说是笔不小的成本;而且不同大模型对Prompt的理解有偏差,切分结果稳定性不如规则方法。

实际项目中,我见过一个折中的混合用法:先用规则或Embedding方法粗切,把明显过长的块挑出来,只把长块和边界模糊的块交给LLM二次判断。这样既控制成本,又能借助LLM的处理能力解决难啃的边界。

3.3 路线三:混合策略(规则 + 相似度 + 长度控制)的项目实测

我在某个知识库项目里最终用的,是规则相似度加长度控制的混合策略。具体的处理流水线是这样:

  • 第一步,按换行符、Markdown标题等强规则做一级切分。文档有明确章节结构时,标题本身就是天然的语义边界,不需要依赖模型判断。
  • 第二步,对一级切分后的大块做二级切分。先按自然段拆,段落内部再用相似度突变检测是否有多主题混排。
  • 第三步,对过短的块做相邻合并,至少达到模型可接受的最低长度(比如100个token),避免检索时信息量不足。
  • 第四步,对过长块做强制拆分,比如超过700个token就找到最接近中间点的低相似度位置切开。

这种混合策略的好处是很稳,每一层都有明确规则兜底,不会出现极端情况——要么整篇一个块、要么几十字的碎块。对于生产项目来说,稳定压倒一切,宁可偶尔切得不那么完美,也不能让分块逻辑在某个边界case里彻底失控。

4. 效果验证与调参:怎么证明语义分块确实提升了检索精度

4.1 设计一个可复现的检索评估实验

改完分块方案,得用数据证明它确实有效,不能只凭感觉说“效果变好了”。我的做法是搭一个简单但可复现的评估数据集。

数据集结构:

  • 从知识库里抽取100个有明确答案的问题(Query);
  • 为每个问题人工标注1-2个相关文档段落(Ground Truth);
  • 评估指标用Recall@K:前K个检索结果中,包含Ground Truth的比例。

检索不做额外重排(Rerank),只用向量相似度,这样才能单独看分块方式对召回率的贡献。

我做了固定分块与语义分块的对比实验,随机选取没有重叠token的测试片段,两组都用相同的Embedding模型和同一个向量库,唯一变量就是分块方式。表里是一轮验证的数据:

分块方式平均块长(token)Recall@5平均单Query检索时间
固定320token + 20重叠3180.7138ms
固定512token + 40重叠5080.6941ms
语义分块(相似度)2870.8345ms
语义分块(混合策略)3050.8547ms

语义分块的Recall@5比固定分块高出十多个百分点,这个提升在RAG场景里非常明显——下游生成模型拿到正确上下文的概率大幅增加,回答质量的提升是水到渠成的事。

检索时间持平甚至略高一点,原因在于块数量变多了,但多出的耗时可以忽略,而且可以通过向量库的索引优化压回40ms以内。

4.2 调参经验:阈值、窗口、块长控制的三组实测数据

如果你想调整相似度阈值和窗口大小,我把我这边实测过的数据列出来参考。

阈值窗口平均块长效果反馈
0.703412 token块偏大,部分块包含多个话题
0.753312 token粒度适中,块语义相对独立
0.755351 token边界更宽松,长块变多
0.783256 token块偏碎,检索噪声增大

从这组数据能看出两个规律:阈值升高会让切分点变多、块变短;窗口扩大会引入更多整体文本信息,边界检测会更平滑,但可能漏掉一些局部突变。实际调到什么程度,要结合语料的句子长度和主题密度来定。

如果语料句子普遍很长,窗口可以调到5;如果语料句子短,窗口2-3就够了。阈值方面,我的习惯是先拿15到20个文档做人工标注,切分结果人工检查一遍,看切分点是否落在语义自然边界上,以此确定初始值,再放到评测集里做微调。

4.3 容易被忽视的坑:段首、段尾、噪声文本的影响

调试语义分块时,有三个坑特别容易踩。

第一个坑是段首段尾的噪声。有些文档开头有一段题记、版权声明,或者文档末尾挂了一长串URL和引用列表。它们的向量方向和正文差异很大,很容易被切分成“语义孤岛”。但这类块在检索时价值极低,反而会把每块的平均质量拉低。更好的处理是,在切分前把它们识别出来并剔除,不参与建库或检索。

第二个坑是文本清洗不彻底导致语义漂移。文档里如果有乱码、错误编码、多余换行,会影响分句和向量质量。我遇到过一份PDF转出来的文本,所有换行都在行尾,一个自然段被拆成七八行,直接按换行切分就切得稀碎。解决方法是先做一轮以段落为中心的文本归一化,把单行断裂的段落合并回去,再送进分块流程。

第三个坑是Embedding模型本身有长度上限。多数Embedding模型最长支持512个token的输入,如果切出来的块超过这个长度,编码时会被截断,向量语义丢失。所以块长上限建议设置在400-480个token之间,留出足够余量,保证整块能够完整编码进向量。

4.4 进阶调整:按检索场景调整语义块粒度

最后想多说一点,语义分块并不能“一次调好,永久适用”。不同检索场景下,最优块粒度差别很大。

如果用户查询偏具体,比如“某个螺栓的扭矩标准是多少”,相关信息通常在一个小段落里就能找到,此时块粒度可以适当细一点,有助于精确定位。如果用户偏概览,比如“这套设备的维护流程是什么”,需要覆盖多个步骤的逻辑链条,块粒度太细反而会丢失流程完整性,需要偏大的块或父块子块结合的方案。

还有一类多轮对话场景,用户先问A再追问A的细节,第二轮往往带有代词,比如“它的参数是多少”。这种情况下,检索时需要额外携带用户当前问题的上下文摘要,和语义分块配合,才能让“它”这个指代为下游模型所理解。

我的经验是,语义分块的核心目标不是把块做小或做大,而是让块围绕语义边界自然形成,再根据业务场景,在“语义完整”和“检索精确”之间做调整。理解了这一点,分块方案才能真正服务于RAG的上游精度。

我在实际项目里反复调了一轮,最后混合策略的这套流程被固化下来,检修知识库的召回精度稳定在85%以上,后续基本没再出现“找不到答案”的尴尬情况。语义分块不是什么神秘的黑魔法,它只是尊重了文本本身的语义结构,让Embedding模型在更完整的输入上工作——就这么一个朴素的改变,效果却比很多花哨的检索调参都来得直接。如果你也正在被RAG的召回质量折磨,不妨从分块这一步开始检查,大概率会有惊喜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进