相比固定字符数切分,语义分块通过句子嵌入相似度检测主题切换点,确保完整语义单元落入同一块。实验中TF-IDF即可实现,无需调用外部API。
检索增强生成从不会把整个文档喂给模型——它喂的是片段。所以模型能给出的答案上限就是一个片段所能容纳的内容。如果分块把一个观点拆成了两半,那么被优先检索到的片段就只有半个答案,再多的提示词技巧也找不回缺失的那一半。分块是一个上游的质量决策,在任何查询运行之前就已经确定——它悄然决定了你的 RAG 系统能达到什么水平。
固定大小分块无论语义如何,每隔 N 个字符(或 N 个句子)就切一刀。这很简单,也不需要任何嵌入向量,但断点落在计数器归零的地方——往往正好把一个主题拦腰截断。一个定义和它的例子被分到不同片段;一个问题和它的答案被拆散。信息都在,只是永远无法作为一个可检索的单元聚在一起。
语义分块改为在语义的接缝处切分。先对每个句子做嵌入,计算每个句子与其相邻句子的相似度,然后在相邻相似度下降的位置插入断点。同一个主题在延续时,相似度高;主题发生转换时,向量方向不同,余弦相似度下降。
![https://cos.poetries.top/images/docs/240830150602838.png]
演示使用轻量级 TF-IDF 词袋模型进行嵌入(确定性,无需调用 API),然后遍历句子逐一测量相邻余弦相似度——这 n-1 个数字序列就是分块器全部所依据的信号:
def adjacent_sims(vecs):
return [cosine(vecs[i], vecs[i + 1]) for i in range(len(vecs) - 1)]
sims = adjacent_sims(vecs)
# 例如 [0.55, 0.41, 0.60, 0.00, 0.48, ...] -> 0.00 就是一个主题切换点
相似度下降多少才算"足够大"值得切分?用固定阈值(如 sim < 0.3)在不同相似度分布的文档上会失效。改用距离 = 1 − 相似度,再按距离缺口的百分位数设定阈值。p = 85 表示只切分最大的前 15% 跳变;百分位数越低,门槛越低,符合条件的缺口越多,得到的片段也越小、越多。这就是 LangChain 的 breakpoint_threshold_type="percentile":
def percentile(xs, p):
s = sorted(xs)
if not s: return 0.0
i = (p / 100) * (len(s) - 1)
lo, hi = math.floor(i), math.ceil(i)
return s[lo] + (s[hi] - s[lo]) * (i - lo) # 线性插值
def breakpoints(sims, p=85):
dist = [1 - s for s in sims]
thr = percentile(dist, p)
return {i for i, d in enumerate(dist) if d >= thr} # 需要切分的缺口
# p 越低 -> 阈值越低 -> 越多的缺口通过 -> 片段越多、越小
遍历句子;在第 i 句之后有断点,意味着第 i+1 句开启一个新片段。两个断点之间的一切就是一段彼此高度相似的句子——一个连贯、自包含的观点:
def semantic_chunks(sents, p=85):
vecs = tfidf_vectors(sents)
sims = adjacent_sims(vecs)
brks = breakpoints(sims, p)
chunks, cur = [], [sents[0]]
for i in range(len(sents) - 1):
if i in brks: # 第 i 句和第 i+1 句之间的接缝
chunks.append(" ".join(cur)); cur = []
cur.append(sents[i + 1])
chunks.append(" ".join(cur))
return chunks
对于一篇内容涉及光合作用 → 罗马帝国 → 浓缩咖啡的文档,一个合适的百分位数能将两个断点恰好落在真实的主题边界上,从而每个片段都是一个干净独立的主题——检索器返回的是完整的答案,而不是支离破碎的故事。
片段过大会稀释嵌入向量,并在提示词中塞入大量无关内容;片段过小则一个事实会丧失使其可回答的上下文。略微重叠能起到保险作用——把最后一句或两句重复到下一个片段中,防止一个断点把论点和论据拆开。语义分块决定了在哪里切;片段大小和重叠度则调优有多少上下文随片段一起传递。
![https://cos.poetries.top/images/docs/240830150625193.png]
生产环境中,把 TF-IDF 这个玩具替换成真正的嵌入器(Anthropic 没有 embeddings 端点——他们推荐 Voyage AI,或者用 LangChain 的 SemanticChunker),对片段建立索引,检索出 top 片段,再交给模型。递归分块尊重结构;语义分块尊重语义——当文档混合了多个主题且没有清晰结构、同时检索质量又很关键时,就选用它。
调节百分位数,观察句子条带重新着色为语义片段,并与固定大小分块进行并排检索对比:https://dev48v.infy.uk/ai/days/day55-semantic-chunking.html