2890 字
约 9 分钟
14
S20-J06 · 切分与召回:分块策略对检索质量的真实影响

检索质量差的时候,第一反应是"换个更好的 embedding 模型"。但多数情况下,模型没变、查询没变,只是答案被切在了两个块的交界处。切分决定了向量里能装下多少可被检索的语义,这一步做错,后面的重排、改写都只是在给已经丢掉的信息补妆。


1. 三个互相拉扯的目标

目标 追求它 代价
语义完整 块内自洽,能独立回答一个问题 块变大,向量被多主题稀释
检索粒度 一块只讲一件事,相似度锋利 块变小,指代与前提被切走
上下文预算 塞得进 prompt,成本可控 top-k 变小,召回变脆

三者没有同时最优解。工程上要做的是先定预算,再定粒度,最后用父子块把完整性补回来,而不是找一个"最佳 chunk size"。


2. 五种切分方式与适用文档

方式 边界依据 适用文档 失效场景
固定长度 字符/token 数 日志、无结构转录稿 表格、代码、法条
段落/标题 空行、# 层级 Markdown、说明书 长段落不分节的老文档
递归分隔符 段落→句→词逐级回退 通用默认,混排文档 无标点的中文长句
语义切分 相邻句向量相似度断点 长论文、访谈 成本高、断点不稳定
按文档结构 表格行、代码函数、HTML 节点 报表、代码库、网页 结构不规范时退化

语义切分要用一次 embedding 预算换断点质量,对"一段话讲三件事"的材料才有价值;结构规范的材料用规则切分就够了。

from langchain_text_splitters import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter

md = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
    strip_headers=False)          # 标题留在正文里,否则块内丢失主题词

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800, chunk_overlap=120,
    separators=["\n\n", "\n", "。", ";", ",", ""])

中文必须把"。"和";"放进分隔符列表,否则递归回退会一路退到单字,块边界毫无语义。


3. size 与 overlap:两个旋钮各自管什么

现象 机制 调法
长问题检索不到 块太小,只覆盖问题的局部 加 size,或上父子块
召回结果都"沾边但不准" 块太大,向量被均值化 减小 size,下压到单主题
同一段落反复出现 overlap 过大,跨块重复 overlap 降到 size 的 10%–15%
交界处答案永远缺一句 overlap 不够 提升到 15%–20%,或按句对齐
表格被切成两半、列名丢失 固定长度无视结构 改按结构切,整表 + 表头一起走

size 的判据不在"字符数好看",而在问题类型:事实型查询("X 的参数是多少")要小块;比较型、汇总型查询("这三种方案的区别")要大块或父子块。因此一个库里常需要两种粒度的索引,而不是一个折中的数值。

overlap 的本质是为边界处买保险,保费是存储与召回冗余。它不能修复错误的分隔符顺序——如果先按字符切再想按句切,overlap 只会把破碎重复一遍。


4. 元数据:过滤的抓手与引用的凭据

字段 用途 不带会怎样
doc_id / chunk_id 定位、去重、增量更新 无法删除旧块,只能重建全库
section_path 引用可读、块内补全语境 用户看到"第 3 段",无法验证
source_url / page 溯源 答案不可审计
updated_at 时间过滤、新老版本取舍 "上个月的政策"召回三年前的
acl / tenant 权限前置过滤 检索到无权内容,或被过滤后召回崩掉
lang / doc_type 路由到不同索引 代码与散文混排,互相污染

权限字段必须在检索前参与过滤,而不是拿回结果再剔除——后者会让 top-k 里一半是无效块,召回率被静默吃掉。


5. small-to-big:小块检索,大块喂入

同一份内容建两条路径:细块进向量库负责"被找到",父块(整节或整段)负责"被读懂"。

{ "chunk_id": "doc42#s3#p2", "parent_id": "doc42#s3",
  "text": "……这一小节里的第二段……",
  "section_path": "第 3 章 > 3.2 限流 > 令牌桶",
  "updated_at": "2026-10-02", "acl": ["eng"] }

检索命中 chunk_id 后按 parent_id 回表取原文,喂给模型的是完整小节。要点有三:命中块只用于打分,不直接进 prompt;父块要设长度上限,否则又回到"稀释";chunk_id 必须可推导(文档号 + 结构路径 + 序号),否则增量更新时定位不到旧块。

代价是两套存储与一次回表查询,收益是"精度按小块算、完整性按大块给",这是目前性价比最高的一种折中。


6. 混合检索为什么互补,而不是重复

BM25 打的是词面共现,向量打的是语义邻近。两者失效的方向几乎不重叠:编号、错误码、人名、缩写只有字面能命中;同义改写、跨语言、口语化只有向量能命中。所以融合的价值在于覆盖不同的查询族,不是把两个分数平均一下。

def rrf(rank_lists, k=60, w=None):
    w = w or [1.0] * len(rank_lists)
    score = {}
    for lst, weight in zip(rank_lists, w):
        for rank, doc in enumerate(lst):        # rank 从 0 开始
            score[doc] = score.get(doc, 0) + weight / (k + rank + 1)
    return sorted(score, key=score.get, reverse=True)

k 越大,排名差异被压平,长尾块更容易浮上来;k 越小,头部名次主导。融合前要先去重(同一 parent_id 的多个块只保留最高名次),否则同一段内容会占满 top-k,等于自己给自己刷票。


7. 召回评估:口径先定,再谈优化

指标 口径 常见误用
Recall@k 标注相关块中,前 k 条覆盖的比例 只报命中"至少一个"的命中率
MRR 第一个相关块名次的倒数均值 多相关块场景下低估
nDCG@k 带分级相关度的位置折扣 相关性只标 0/1,退化成 MRR
答案命中率 端到端,看生成对不对 与召回混为一谈,归因错位

查询集要覆盖查询族:精确词、同义改写、多跳、带约束(时间/权限)。每族至少几十条才能看出差异,样本太少时排序噪声比改进还大。

{"qid":"q017","query":"令牌桶和漏桶的区别","relevant":["doc42#s3#p2","doc42#s3#p5"],"grades":[2,1]}

标注粒度是块级而不是文档级——文档级标注会让"召回对了文档、块选错了"这种真实故障完全不可见。


8. "检索到了但答不对"的三种归因

归因 判据 改法
块里根本没有答案 标注里该块不相关,或答案只在别的块 补切分/补索引源,不是调 top-k
块被截断,答案缺前提 块边界正好落在关键句中间 加 overlap、按句对齐、父子块
排序被噪声挤掉 相关块在第 8–15 名,top-5 里没有 上重排,或加元数据过滤

区分方法很直接:把"标注相关块"强行放进 prompt,看答案对不对。放进去就对了 → 问题在排序;放进去还不对 → 问题在切分或语料缺失。这一步能把"调模型"的冲动挡回去大半。


9. 重排的成本收益与触发条件

方案 延迟量级 收益来源 该用的条件
只调 top-k 无 多给几条 召回率已高,只是排序差
cross-encoder 重排 与候选数线性相关 查询-文档逐对打分 相关块常在第 6–20 名
加权融合 极低 两条检索路径互补 查询族混合、成本敏感
重排 + 截断 中 先粗排 N=50 再精排 top-5 上下文预算紧张

重排的收益随"候选里真有相关块"而存在:如果 Recall@50 本身就低,重排只能把噪声排得更整齐。先量 Recall@50,再决定要不要付这份延迟。


10. 增量更新与删除

def upsert_chunks(doc_id, chunks):
    old = index.query(filter={"doc_id": doc_id})       # 先按 doc 维度取旧块
    keep = {c["chunk_id"] for c in chunks}
    for c in old:
        if c["chunk_id"] not in keep:
            index.delete(c["chunk_id"])               # 结构变了,旧块要清
    index.upsert(chunks)

三条纪律:chunk_id 由内容结构推导(doc_id + section_path + seq),不要用自增号或随机 uuid,否则删不掉旧块;删除按 doc_id 维度先查后删,不要指望记忆;改了切分参数就必须全量重建——旧块的边界与新规则不一致,混在一起会让相似度分布不可比。

只更新不删除是最常见的隐性 bug:文档改短了,旧的长块还留在库里,检索时它比新块更"完整",于是永远召回过期内容。


11. 排错

现象 先查 再查
新文档检索不到 是否已 refresh / 索引是否可见 向量维度与索引 mapping 是否一致
召回全是同一文档 是否缺 doc_id 去重 overlap 是否过大导致重复块
改了切分但效果不变 索引是否真的重建 查询走的是旧别名吗
表格类问题总答错 表格是否被按字符切断 表头是否随行一起入库
删除后仍能召回 是否只删了主索引 缓存层与副本是否同步

12. 边界

场景 更合适的做法 理由
语料只有几万字 全量塞进上下文 检索的复杂度不值得
强结构化数据(订单、指标) 走 SQL / 精确查询 向量检索不适合做精确匹配
需要逐字引用的合同 结构切分 + 原文高亮 块内必须能定位到条款号
超高并发低延迟 单路检索 + 缓存 多路召回与重排的延迟叠加

13. 自测

  1. 为什么"找一个最佳 chunk size"这个提法本身有问题?
  2. overlap 能修复什么样的错误,又不能修复什么样的错误?
  3. 父块回表时,为什么命中块不直接进 prompt?
  4. RRF 的 k 变大,会让排序结果偏向什么?
  5. 判断"检索到了但答不对"属于哪一类归因,最快的实验是什么?
  6. 改了切分参数却没有重建索引,会观察到什么现象?
S20-J06 · 切分与召回:分块策略对检索质量的真实影响
https://www.clxhxhhr.top/posts/6048/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。