芥末
发布于 2025-10-29 / 0 阅读
0
0

RAG 文档分块策略实战:从固定长度到混合分块

RAG(Retrieval-Augmented Generation,检索增强生成)系统的效果不只取决于大语言模型和 Prompt。很多问答质量问题,比如上下文缺失、引用不完整、答案凭空补全,根源往往出现在文档入库之前:知识被切碎的方式不对。

一个典型 RAG 流程大致如下:

flowchart LR
    A[原始文档] --> B[清洗与解析]
    B --> C[文档分块 Chunking]
    C --> D[Embedding 向量化]
    D --> E[(向量数据库)]
    F[用户问题] --> G[问题向量化]
    G --> H[向量检索]
    E --> H
    H --> I[重排与上下文组装]
    I --> J[LLM 生成答案]

Chunking 的位置在 Embedding(向量嵌入)之前。也就是说,后面的向量检索、重排、上下文拼接,都是建立在“块”这个基本单位上的。如果块切得太粗,检索结果里会夹杂大量无关信息;如果块切得太细,模型拿到的内容又可能缺少定义、条件、例子和上下文。

合理的分块策略要解决两个问题:

  1. 让检索更准:用户问题能命中真正相关的知识片段。
  2. 让生成更稳:被送入大语言模型的上下文足够完整,可以支撑答案。

1. Chunking 到底在切什么

Chunking 指的是把一篇长文档拆成多个较小文本块,每个块会被单独向量化,并作为检索系统中的基本召回单元。

分块里最常见的两个参数是:

  • chunk_size:单个块的目标大小。
  • chunk_overlap:相邻块之间保留的重叠内容。

chunk_size 与 chunk_overlap 示意

图中可以看到,相邻文本块并不是完全首尾相接,而是保留了一段重复区域。这个重复区域就是 chunk_overlap。它的作用是缓解边界切断问题,比如一句定义在前一个块末尾,解释在后一个块开头,如果没有重叠,检索时很可能只拿到其中一半。

一个块的大小不是越大越好,也不是越小越好。它本质上是在两个目标之间做平衡:

参数太小的问题太大的问题合理目标
chunk_size上下文不足,命中关键词但无法支撑答案噪声多,相似度被稀释,占用上下文窗口覆盖一个完整语义单元
chunk_overlap边界处信息容易断裂索引变大,重复召回增多,重排压力上升保留必要跨块线索

在中文知识库里,可以先用下面的范围做基线:

文档类型chunk_size 建议chunk_overlap 建议
普通说明文档400~800 中文字符10%~20%
技术手册、长句较多文档700~1000 中文字符10%~15%
FAQ、公告、法规条款300~700 中文字符句子级重叠或 10%左右
对话、会议纪要6~12 轮对话1~2 轮对话

重叠比例通常不建议超过 25%。超过这个范围后,索引体积、检索重复率和重排成本都会明显增加,但答案质量不一定继续提升。


2. 为什么 RAG 必须认真做分块

2.1 大语言模型有上下文窗口限制

LLM(Large Language Model,大语言模型)不能无限接收文本。即使使用长上下文模型,把整篇文档直接塞进去也会带来三个问题:

  • 成本高,输入 token 越多,调用成本越高。
  • 信息密度低,真正相关的内容被大量无关文本淹没。
  • 长上下文里模型未必总能稳定抓住关键位置。

所以 RAG 通常不会把整篇文档交给模型,而是先检索出最相关的几个块,再拼成上下文。

2.2 分块粒度影响检索信噪比

向量检索比较的是问题向量和文本块向量之间的相似度。块太大时,一个块里可能同时包含安装、配置、错误码、权限说明等多种内容,向量会变得“平均”,相关信号被稀释。

块太小时,又会出现另一类问题:检索命中了一句话,但这句话缺少前提条件。例如:

可以通过该参数开启缓存。

如果没有上一句,模型根本不知道“该参数”是什么,也不知道缓存作用于哪个模块。

2.3 分块边界影响事实完整性

很多知识不是孤立句子,而是由定义、条件、步骤、示例共同组成的结构:

当 enable_cache=true 时,系统会读取本地缓存。
如果缓存不存在,会回退到远程服务。

如果这两句被切到不同块里,用户问“缓存不存在时怎么办”,检索可能只命中第一句,答案就容易漏掉回退逻辑。

理想的分块应该尽量贴近文档的自然边界:

  • 标题
  • 段落
  • 列表
  • 表格
  • 代码块
  • 问答对
  • 对话轮次
  • 语义话题转折点

3. 基础分块策略

基础分块实现成本低,适合建立第一版 RAG 系统的基线。它不一定最优,但能帮助快速跑通入库、检索和评估链路。

3.1 固定长度分块

固定长度分块按照预设字符数直接切文本,不理解标题、段落和语义。

from langchain_text_splitters import CharacterTextSplitter

splitter = CharacterTextSplitter(
    separator="",        # 纯字符切分
    chunk_size=600,
    chunk_overlap=90,
)

chunks = splitter.split_text(text)

这种方式的优势是简单、快、适配任意文本。缺点也明显:它可能把一句话、一个表格、一段代码切成两半。

维度固定长度分块
实现成本很低
适合场景结构弱、质量要求不高的纯文本基线
主要风险破坏语义边界
调参重点控制块长分布和重叠比例

中文语料可以从 chunk_size=300~800 开始试。如果 Embedding 模型推荐输入长度是 512 或 1024 tokens,可以粗略折算成 350 或 700 左右中文字符作为起点,但最终仍要靠验证集评估。


3.2 句子分块

句子分块先把文本切成句子,再把若干句子聚合成接近目标长度的块。它比固定长度分块更尊重语义完整性。

适合场景包括:

  • 法律法规
  • 新闻公告
  • FAQ
  • 产品说明
  • 以自然语言句子为主的知识库

中文分句不能直接套英文分句器。中文句末标点包括 。!?;,还要处理引号、省略号等边界。简单场景可以先用正则实现:

import re
from typing import List

def split_sentences_zh(text: str) -> List[str]:
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    return [
        m.group(0).strip()
        for m in pattern.finditer(text)
        if m.group(0).strip()
    ]

def sentence_chunk(text: str, chunk_size: int = 600, overlap: int = 80) -> List[str]:
    sentences = split_sentences_zh(text)
    chunks = []
    buf = ""

    for sentence in sentences:
        if len(buf) + len(sentence) <= chunk_size:
            buf += sentence
        else:
            if buf:
                chunks.append(buf)

            tail = buf[-overlap:] if overlap > 0 and len(buf) > overlap else ""
            buf = tail + sentence

    if buf:
        chunks.append(buf)

    return chunks

chunks = sentence_chunk(text, chunk_size=600, overlap=90)

如果文档质量复杂,比如存在大量中英文混排、括号、项目符号和不规范标点,可以换成中文 NLP(Natural Language Processing,自然语言处理)工具做更稳健的分句,例如 HanLP、Stanza 或 spaCy 中文生态。

句子分块的常见问题是块过短。解决办法不是直接放弃句子边界,而是把多个句子合并到目标长度附近,并设置最小块长:

MIN_CHUNK_CHARS = 300
TARGET_CHUNK_CHARS = 700

3.3 递归字符分块

递归字符分块会按照一组“从粗到细”的分隔符逐层尝试切分。它会优先按标题、段落、换行切;如果仍然超长,再退到空格甚至字符级。

from langchain_text_splitters import RecursiveCharacterTextSplitter

separators = [
    r"\n#{1,6}\s",             # Markdown 标题
    r"\n\d+(?:\.\d+)*\s",      # 编号标题,例如 1. / 2.3.
    "\n\n",                    # 段落
    "\n",                      # 行
    " ",                       # 空格
    "",                        # 兜底字符级
]

splitter = RecursiveCharacterTextSplitter(
    separators=separators,
    chunk_size=700,
    chunk_overlap=100,
    is_separator_regex=True,
)

chunks = splitter.split_text(text)

递归分块是很多 RAG 系统的默认选择,因为它在实现复杂度和效果之间比较均衡。

优点缺点
能保留大部分自然边界对表格、代码等强结构内容不够精细
参数少,容易作为基线分隔符顺序不合理时会导致块长不稳定
适配说明文档、知识库条目、报告无法理解真正的语义话题变化

中文文档建议:

chunk_size = 400  # 到 800 之间试验
chunk_overlap = 80  # 约 10%~20%

如果块长分布出现长尾,说明粗粒度边界不够,需要增加标题、编号、特殊分隔符。如果块普遍太短,说明分隔符太敏感,可以降低某些分隔符优先级,或者在切分后做短块合并。


4. 结构感知分块

结构感知分块不再只看字符长度,而是利用文档已有结构来决定边界。对于 Markdown、HTML、PDF 解析后的文档、技术手册、接口文档,这类策略通常比基础分块更可靠。

核心思想是:

flowchart TD
    A[解析文档结构] --> B[识别标题、段落、列表、表格、代码块]
    B --> C[按章节生成父级结构块]
    C --> D{结构块是否超长}
    D -- 否 --> E[直接作为候选块]
    D -- 是 --> F[按子标题、段落、句子二次切分]
    E --> G[合并过短块]
    F --> G
    G --> H[写入标题路径和来源 metadata]

4.1 Markdown 结构分块

Markdown 文档天然包含标题层级,分块时应该保留标题路径。例如,一个块来自:

RAG 指南 > 检索优化 > 重排策略

那么这条路径应该写入块文本前缀或 metadata。这样做有两个好处:

  1. 检索时能利用章节语义。
  2. 生成答案时可以追溯来源位置。

下面是一个简化版 Markdown 结构分块器:

import re
from typing import Dict, List

HEADING_PATTERN = re.compile(r'^(#{1,6})\s+(.*)$')
FENCE_PATTERN = re.compile(r'^```')

def split_markdown_structure(
    text: str,
    chunk_size: int = 900,
    min_chunk: int = 250,
) -> List[Dict]:
    lines = text.splitlines()
    sections = []

    in_code = False
    path_stack = []
    current = {
        "level": 0,
        "title": "",
        "content": [],
        "path": [],
    }

    for line in lines:
        if FENCE_PATTERN.match(line):
            in_code = not in_code

        match = HEADING_PATTERN.match(line) if not in_code else None

        if match:
            if current["content"]:
                sections.append(current)

            level = len(match.group(1))
            title = match.group(2).strip()

            while path_stack and path_stack[-1][0] >= level:
                path_stack.pop()

            path_stack.append((level, title))
            breadcrumbs = [title for _, title in path_stack]

            current = {
                "level": level,
                "title": title,
                "content": [],
                "path": breadcrumbs,
            }
        else:
            current["content"].append(line)

    if current["content"]:
        sections.append(current)

    chunks = []

    def emit_chunk(text_block: str, section: Dict):
        clean_text = text_block.strip()
        if not clean_text:
            return

        breadcrumbs = section["path"]
        prefix = " > ".join(breadcrumbs[-3:])
        chunk_text = f"[{prefix}]\n{clean_text}" if prefix else clean_text

        chunks.append({
            "text": chunk_text,
            "meta": {
                "section_title": breadcrumbs[-1] if breadcrumbs else "",
                "breadcrumbs": breadcrumbs,
                "section_level": section["level"],
            }
        })

    for section in sections:
        raw = "\n".join(section["content"]).strip()
        if not raw:
            continue

        if len(raw) <= chunk_size:
            emit_chunk(raw, section)
            continue

        paragraphs = [p.strip() for p in raw.split("\n\n") if p.strip()]
        buf = ""

        for paragraph in paragraphs:
            if len(buf) + len(paragraph) + 2 <= chunk_size:
                buf = f"{buf}\n\n{paragraph}" if buf else paragraph
            else:
                if buf:
                    emit_chunk(buf, section)
                buf = paragraph

        if buf:
            emit_chunk(buf, section)

    merged = []
    for chunk in chunks:
        if not merged:
            merged.append(chunk)
            continue

        same_section = (
            merged[-1]["meta"]["breadcrumbs"] == chunk["meta"]["breadcrumbs"]
        )
        too_short = len(chunk["text"]) < min_chunk

        if same_section and too_short:
            merged[-1]["text"] += "\n\n" + chunk["text"]
        else:
            merged.append(chunk)

    return merged

结构分块时要把代码块、表格、公式当成原子单元。代码和注释被切开后,检索命中一半代码很难支撑正确答案;表头和数据分离后,模型也无法判断字段含义。


4.2 HTML 和 PDF 的结构处理

HTML 可以通过 DOM(Document Object Model,文档对象模型)遍历标题、段落、列表、代码块和表格。PDF 更麻烦,因为它经常带有页眉、页脚、页码、水印和分栏,入库前应该先清洗噪声。

来源格式解析重点常见噪声分块建议
Markdown标题、列表、代码块较少按标题树切父块
HTMLH1~H6、p、li、pre、table导航栏、版权、侧边栏DOM 解析后按语义节点切
PDF段落、表格、页码、版面顺序页眉页脚、水印、断行先做版面清洗,再结构分块
Word标题样式、表格、批注页眉页脚、修订痕迹读取样式层级,保留表格原子性

结构感知分块的 metadata 很关键,至少建议保留:

{
  "doc_id": "manual-001",
  "breadcrumbs": ["用户指南", "安装", "Docker 部署"],
  "section_title": "Docker 部署",
  "block_type": "paragraph",
  "start_offset": 1024,
  "end_offset": 1840,
  "source": "docs/install.md"
}

metadata 不只是为了展示来源,也可以用于过滤、重排、邻接扩展和去重。


4.3 对话式分块

客服记录、会议纪要、访谈和工单不能按普通段落切。对话的基本语义单元通常是“轮次”或“问答对”。

对话式分块应该满足两点:

  • 不拆开明显的问答对。
  • 重叠按轮次做,而不是按字符做。
from typing import Dict, List

def chunk_dialogue(
    turns: List[Dict],
    max_turns: int = 10,
    max_chars: int = 900,
    overlap_turns: int = 2,
) -> List[Dict]:
    """
    turns 示例:
    [
        {"speaker": "User", "text": "订单为什么还没发货?", "ts_start": 10},
        {"speaker": "Agent", "text": "我帮您查询一下物流状态。", "ts_start": 15}
    ]
    """
    chunks = []
    i = 0

    while i < len(turns):
        j = i
        char_count = 0
        speakers = set()

        while j < len(turns):
            turn = turns[j]
            text_len = len(turn["text"])

            if (j - i + 1) > max_turns or (char_count + text_len) > max_chars:
                break

            char_count += text_len
            speakers.add(turn["speaker"])
            j += 1

        window = turns[i:j] if j > i else [turns[i]]
        text = "\n".join(
            f'{turn["speaker"]}: {turn["text"]}'
            for turn in window
        )

        chunks.append({
            "text": text,
            "meta": {
                "speakers": list(speakers),
                "turns_range": (i, j - 1),
                "ts_start": window[0].get("ts_start"),
                "ts_end": window[-1].get("ts_end"),
            }
        })

        if j >= len(turns):
            break

        next_start = i + len(window) - overlap_turns
        i = max(next_start, i + 1)

    return chunks

参数可以从下面的范围开始:

参数建议值
max_turns6~12 轮
max_chars600~1000 字
overlap_turns1~2 轮

检索阶段还可以做邻接扩展:如果某个对话块被召回,可以把它前后各 1~2 轮一起拼进最终上下文,避免模型只看到回答而看不到问题。


5. 语义与主题分块

结构分块依赖文档格式。如果文档没有清晰标题,或者虽然有标题但内部话题变化复杂,就需要从语义上判断边界。

5.1 语义分块

语义分块的核心做法是:先按句子切分,再计算句子向量;当相邻句子或相邻窗口之间的语义相似度明显下降时,把它作为切分点。

flowchart LR
    A[文本] --> B[中文分句]
    B --> C[句子向量化]
    C --> D[计算相邻语义变化]
    D --> E{是否发生语义突变}
    E -- 是 --> F[切分]
    E -- 否 --> G[继续累积]
    F --> H[长度约束与短块合并]
    G --> H

一个可运行的简化实现如下:

import re
from typing import Dict, List

import numpy as np
from sentence_transformers import SentenceTransformer

def split_sentences_zh(text: str) -> List[str]:
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    return [
        m.group(0).strip()
        for m in pattern.finditer(text)
        if m.group(0).strip()
    ]

def semantic_chunk(
    text: str,
    model_name: str = "BAAI/bge-m3",
    window_size: int = 2,
    min_chars: int = 350,
    max_chars: int = 1100,
    lambda_std: float = 0.8,
) -> List[Dict]:
    sentences = split_sentences_zh(text)
    if not sentences:
        return []

    model = SentenceTransformer(model_name)
    embeddings = model.encode(
        sentences,
        normalize_embeddings=True,
        batch_size=64,
        show_progress_bar=False,
    )
    embeddings = np.asarray(embeddings)

    novelties = []
    for i in range(len(sentences)):
        if i == 0:
            novelties.append(0.0)
            continue

        start = max(0, i - window_size)
        ref = embeddings[start:i].mean(axis=0)
        ref = ref / (np.linalg.norm(ref) + 1e-8)

        novelty = 1.0 - float(np.dot(embeddings[i], ref))
        novelties.append(novelty)

    novelties = np.asarray(novelties)
    threshold = float(novelties.mean() + lambda_std * novelties.std())

    chunks = []
    buf = ""
    start_idx = 0

    def flush(end_idx: int):
        nonlocal buf, start_idx

        if buf.strip():
            chunks.append({
                "text": buf.strip(),
                "meta": {
                    "start_sent": start_idx,
                    "end_sent": end_idx - 1,
                }
            })

        buf = ""
        start_idx = end_idx

    for i, sentence in enumerate(sentences):
        over_max = len(buf) + len(sentence) > max_chars
        can_split = len(buf) >= min_chars

        if over_max and can_split:
            flush(i)

        buf += sentence

        semantic_shift = novelties[i] > threshold
        if can_split and semantic_shift:
            flush(i + 1)

    if buf:
        flush(len(sentences))

    return chunks

这里的 novelty 可以理解为“新句子和前面上下文有多不一样”。阈值越低,切得越碎;阈值越高,切得越保守。

中文技术文档可以从这些参数开始:

参数建议
window_size2~4 句
min_chars300~400
max_chars1000~1200
lambda_std0.6~1.0
重叠附加上一句,或 10%左右字符重叠

语义分块适合专题性强、论证结构明显的材料,比如白皮书、技术方案、研究报告和长 FAQ 聚合页。小文档不适合过度使用,因为句子数量太少时,相似度分布不稳定。


5.2 主题分块

主题分块关注更宏观的话题变化。它不是判断“这一句和上一句像不像”,而是判断一段时间内文本是否切换到了另一个主题。

常见流程是:

  1. 中文分句或分段。
  2. 生成句向量。
  3. 用聚类算法给句子打主题标签。
  4. 对标签序列做滑窗平滑,避免频繁抖动。
  5. 当主题稳定变化且满足最小长度时切分。
import re
from typing import Dict, List

import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans

def split_sentences_zh(text: str) -> List[str]:
    pattern = re.compile(r'([^。!?;]*[。!?;]+|[^。!?;]+$)')
    return [
        m.group(0).strip()
        for m in pattern.finditer(text)
        if m.group(0).strip()
    ]

def topic_chunk(
    text: str,
    k_topics: int = 5,
    min_chars: int = 500,
    max_chars: int = 1400,
    smooth_window: int = 2,
    model_name: str = "BAAI/bge-m3",
) -> List[Dict]:
    sentences = split_sentences_zh(text)
    if not sentences:
        return []

    model = SentenceTransformer(model_name)
    embeddings = model.encode(
        sentences,
        normalize_embeddings=True,
        batch_size=64,
        show_progress_bar=False,
    )
    embeddings = np.asarray(embeddings)

    n_clusters = min(k_topics, len(sentences))
    kmeans = KMeans(n_clusters=n_clusters, n_init="auto", random_state=42)
    labels = kmeans.fit_predict(embeddings)

    smoothed = labels.copy()
    for i in range(len(labels)):
        start = max(0, i - smooth_window)
        end = min(len(labels), i + smooth_window + 1)

        values, counts = np.unique(labels[start:end], return_counts=True)
        smoothed[i] = int(values[np.argmax(counts)])

    chunks = []
    buf = ""
    start_idx = 0
    current_label = smoothed[0]

    def flush(end_idx: int):
        nonlocal buf, start_idx

        if buf.strip():
            chunks.append({
                "text": buf.strip(),
                "meta": {
                    "start_sent": start_idx,
                    "end_sent": end_idx - 1,
                    "topic": int(current_label),
                }
            })

        buf = ""
        start_idx = end_idx

    for i, sentence in enumerate(sentences):
        switched = smoothed[i] != current_label
        under_min = len(buf) < min_chars
        over_max = len(buf) + len(sentence) > max_chars

        if switched and not under_min:
            flush(i)
            current_label = smoothed[i]

        if over_max and not under_min:
            flush(i)

        buf += sentence

    if buf:
        flush(len(sentences))

    return chunks

主题数 k_topics 很难一次设准。可以用轮廓系数(silhouette score)或肘部法做初筛,再结合领域知识调整。对于一篇只有几段的小文档,主题分块通常不如结构分块或语义分块稳定。


6. 高级分块:小块召回,大块回答

基础策略和结构策略解决的是“怎么切”。高级策略更关注“怎么检索和组装上下文”。

6.1 小-大分块

小-大分块的思路很直接:

  • 用小块做检索,因为小块更容易精准命中问题。
  • 用大块做回答上下文,因为大块保留了定义、解释、例子和上下游条件。
sequenceDiagram
    participant Q as 用户问题
    participant S as 小块向量索引
    participant P as 父块存储
    participant R as 重排器
    participant L as 大语言模型

    Q->>S: 检索 top_k 小块
    S-->>Q: 返回命中句子或短段
    Q->>P: 按 parent_id 找到父块
    P-->>Q: 返回段落/小节上下文
    Q->>R: 对父块候选重排
    R-->>Q: 返回排序后的上下文
    Q->>L: 组装上下文并生成答案

伪代码如下:

# 离线阶段:
# 1. 建小块索引:句子、短段、子句
# 2. 存父块内容:段落、小节、章节
# 3. 保存 child_id -> parent_id 映射

small_hits = small_index.search(embed(query), top_k=30)

groups = group_by_parent_id(small_hits)

scored_parents = score_groups(
    groups,
    strategy="max_mean_coverage",
)

candidate_parent_ids = top_m(scored_parents, m=3)

rerank_inputs = [
    (query, parent_store[parent_id]["text"])
    for parent_id in candidate_parent_ids
]

reranked_parent_ids = cross_encoder_rerank(rerank_inputs)

contexts = []
for parent_id in reranked_parent_ids:
    hit_children = groups[parent_id]
    context = build_local_window(
        parent_text=parent_store[parent_id]["text"],
        hit_children=hit_children,
        window_sents=1,
    )
    contexts.append(context)

final_context = pack_under_budget(contexts, token_budget=3000)

这种方式比“只检索大块”更精准,也比“只把小块送给模型”更完整。


6.2 父子段分块

父子段分块是小-大分块的工程化建模方式。它显式维护父块和子块之间的关系。

flowchart TD
    A[文档] --> B[父块:章节 / 段落 / 小节]
    B --> C[子块:句子 / 短段 / 子句]
    C --> D[(子块向量索引)]
    B --> E[(父块原文存储)]
    F[查询] --> D
    D --> G[召回子块]
    G --> H[按 parent_id 聚合]
    H --> E
    E --> I[取命中窗口和标题路径]
    I --> J[重排与上下文拼接]

父子段分块适合这些场景:

场景为什么适合
技术手册用户问题往往命中某句配置说明,但回答需要完整小节
法规条款证据需要句级准确,解释需要条款上下文
FAQ 聚合页问题匹配适合小块,答案生成需要问答对完整
白皮书长段落中只有局部相关,需要抽取命中窗口

父块和子块建议保存这些字段:

{
  "parent": {
    "parent_id": "doc-001#section-03",
    "text": "完整小节文本",
    "breadcrumbs": ["部署指南", "缓存配置"],
    "start_offset": 1200,
    "end_offset": 2600
  },
  "child": {
    "child_id": "doc-001#section-03#sent-05",
    "parent_id": "doc-001#section-03",
    "text": "当 enable_cache=true 时,系统会优先读取本地缓存。",
    "start": 410,
    "end": 460
  }
}

父块聚合打分可以用:

score_parent =
    α * max(child_scores)
  + (1 - α) * mean(child_scores)
  + β * coverage

其中:

  • max(child_scores) 表示父块里最强命中。
  • mean(child_scores) 表示整体相关性。
  • coverage 表示命中的子块覆盖程度。

还要注意长父块偏置。父块越长,子块越多,天然更容易被命中。可以加入长度归一化:

density = sum(exp(score_i)) / len(parent_text)

6.3 Agent 式分块

Agent 式分块是让一个低温度、强约束的大语言模型参与边界判断。它适合高度复杂的混合文档,比如一份材料里同时有说明文字、代码、表格、公式和长篇叙述,普通规则难以切出稳定边界。

关键不是让模型自由发挥,而是要求它输出结构化边界:

系统指令:
你是一个 RAG 分块器。目标是创建高内聚、可追溯的文本块。

规则:
1. 不得在代码块、表格、公式中间切分。
2. 每块长度控制在 400~1000 字。
3. 保留标题路径。
4. 尽量让“定义 + 解释 + 示例”留在同一块。
5. 只输出 JSON。

输出格式:
{
  "segments": [
    {
      "start": 0,
      "end": 812,
      "title_path": ["指南", "安装"],
      "reason": "完整安装步骤和注意事项"
    }
  ]
}

Agent 输出后必须经过校验器:

校验项目的
起止 offset 是否递增防止边界错乱
是否越界防止截取非法文本
是否切开代码块、表格、公式保持原子结构
是否满足最小/最大长度控制块粒度
是否覆盖全文防止遗漏内容

如果校验失败,应该自动回退到递归分块或结构分块。Agent 式分块成本较高,适合只处理疑难块,而不是对所有文档全量调用。


7. 混合分块:更适合生产环境的方案

单一策略很难覆盖所有文档。生产环境里更常见的是混合分块:先按结构粗切,再对异常块使用更细策略,检索时配合父子段或小-大上下文组装。

可以把策略选择写成一套调度规则:

flowchart TD
    A[原始文档] --> B[清洗与结构解析]
    B --> C{块类型}
    C -- 代码/表格/公式 --> D[作为原子块保留]
    C -- 对话 --> E[按说话人与轮次切分]
    C -- 普通文本 --> F{是否超长或主题混杂}
    F -- 否 --> G[递归字符分块]
    F -- 是 --> H[语义分块或主题分块]
    D --> I[写入 metadata]
    E --> I
    G --> I
    H --> I
    I --> J[子块索引 + 父块存储]

一个简化版混合调度器如下:

from typing import Callable, Dict, List

def hybrid_chunk(
    doc_text: str,
    parse_structure: Callable,
    recursive_splitter: Callable,
    semantic_splitter: Callable,
    dialogue_splitter: Callable,
    max_coarse_len: int = 1100,
    min_chunk_len: int = 320,
) -> List[Dict]:
    """
    parse_structure 返回示例:
    [
        {
            "type": "text|code|table|formula|dialogue",
            "text": "...",
            "breadcrumbs": ["指南", "安装"],
            "anchor": "install"
        }
    ]
    """
    blocks = parse_structure(doc_text)
    chunks = []

    def emit(text: str, meta: Dict):
        clean_text = text.strip()
        if not clean_text:
            return

        breadcrumbs = meta.get("breadcrumbs", [])
        prefix = " > ".join(breadcrumbs[-3:])
        final_text = f"[{prefix}]\n{clean_text}" if prefix else clean_text

        chunks.append({
            "text": final_text,
            "meta": meta,
        })

    for block in blocks:
        block_type = block.get("type", "text")
        text = block.get("text", "")

        if block_type in {"code", "table", "formula"}:
            emit(text, {**block, "splitter": "atomic"})
            continue

        if block_type == "dialogue":
            for chunk in dialogue_splitter(block.get("turns", [])):
                emit(chunk["text"], {**block, "splitter": "dialogue"})
            continue

        if len(text) <= max_coarse_len:
            sub_chunks = recursive_splitter(text)

            buf = ""
            for sub_chunk in sub_chunks:
                sub_text = sub_chunk["text"]

                if len(buf) + len(sub_text) < min_chunk_len:
                    buf += sub_text
                else:
                    emit(buf or sub_text, {**block, "splitter": "recursive"})
                    buf = ""

            if buf:
                emit(buf, {**block, "splitter": "recursive"})
        else:
            for chunk in semantic_splitter(text):
                emit(chunk["text"], {**block, "splitter": "semantic"})

    return chunks

生产环境可以按质量和成本分成三个档位:

档位分块策略检索策略适合场景
fast结构分块 + 递归分块普通向量检索快速上线、成本敏感
balanced结构分块 + 异常块语义分块小-大检索 + 轻量重排大多数知识库
qualitybalanced + Agent 精修疑难块父子段聚合 + 交叉编码重排高准确率问答、法规、技术支持

8. 分块策略怎么选

不同文档适合不同切法。可以直接用这张表做初始选择:

文档类型推荐策略不建议
普通知识库文章递归分块、结构分块纯固定长度硬切
Markdown 技术文档结构分块 + 递归二次切分忽略标题路径
HTML 页面DOM 解析 + 结构分块把导航栏、页脚一起入库
PDF 报告清洗版面噪声 + 结构/语义分块直接按页切
法规、公告句子分块 + 条款结构 metadata把条款切断
代码文档代码块原子保留 + 解释文字打包切开函数和注释
表格文档表头与数据同行保留表头和表体分离
客服对话对话轮次分块字符级切分
长篇多主题报告结构分块 + 语义/主题分块只设一个固定长度

经验规则可以压缩成几句话:

  • 有结构,先用结构。
  • 没结构但语义清晰,用句子或递归。
  • 话题转折明显,用语义分块。
  • 文档很长且多主题,再考虑主题分块。
  • 答案需要完整上下文,用小-大或父子段检索。
  • 代码、表格、公式不要从中间切开。

9. 调参与评估方法

分块调优不要和检索算法、Embedding 模型、重排器一起乱调。更稳的方式是固定其他组件,只改变分块参数。

9.1 建验证集

验证集至少包含:

[
  {
    "question": "如何开启本地缓存?",
    "gold_doc_id": "cache-guide",
    "gold_answer_spans": [
      "当 enable_cache=true 时,系统会优先读取本地缓存。"
    ]
  }
]

每个问题最好标注:

  • 正确文档 ID
  • 正确章节
  • 正确答案依据片段
  • 期望答案要点

9.2 看检索指标

指标含义
Recall@k前 k 个结果是否召回了正确依据
MRR(平均倒数排名)正确结果排得越靠前分数越高
nDCG(归一化折损累计增益)同时考虑相关性和排序位置
来源命中文档覆盖率是否命中正确来源文档
重复召回率top_k 里是否充满相似重复块
Faithfulness(事实一致性)答案是否被检索上下文支撑

9.3 观察块长分布

除了指标,还要看块长分布:

lengths = [len(chunk["text"]) for chunk in chunks]

print("chunk count:", len(lengths))
print("min:", min(lengths))
print("max:", max(lengths))
print("avg:", sum(lengths) / len(lengths))

可以进一步画分布图:

import matplotlib.pyplot as plt

plt.hist(lengths, bins=30)
plt.xlabel("chunk length")
plt.ylabel("count")
plt.title("Chunk Length Distribution")
plt.show()

常见现象和处理方式:

现象可能原因调整方式
长尾块很多标题/段落分隔符不足增加粗粒度分隔符,降低 chunk_size
块普遍很短分隔符太细,缺少短块合并增加 min_chunk,合并同章节短块
命中很多无关内容块太大,噪声多降低 chunk_size,加强结构边界
答案经常缺上下文块太小或边界切断增加 overlap,使用父子段检索
top_k 重复严重overlap 太大降低 overlap,引入去重
引用定位差metadata 不完整增加 breadcrumbs、offset、source

10. 实战建议

RAG 分块可以按这样的顺序落地:

flowchart TD
    A[建立固定检索链路] --> B[用递归分块做基线]
    B --> C[构造问答验证集]
    C --> D[记录 Recall@k / MRR / nDCG]
    D --> E{问题主要是什么}
    E -- 噪声多 --> F[减小块长或增强结构边界]
    E -- 上下文断裂 --> G[增加重叠或启用父子段]
    E -- 结构丢失 --> H[加入标题路径 metadata]
    E -- 话题混杂 --> I[对异常块做语义分块]
    F --> J[重新评估]
    G --> J
    H --> J
    I --> J

更稳的生产配置通常是:

  1. 文档先清洗,去掉页眉、页脚、导航、重复版权信息。
  2. 能解析结构就先解析结构,标题路径写入 metadata。
  3. 普通文本用递归分块作为默认策略。
  4. 超长、主题混杂的块再用语义分块处理。
  5. 代码、表格、公式作为原子块,和解释文字尽量打包。
  6. 检索侧使用小块召回,大块或局部父块窗口作为上下文。
  7. 用验证集持续评估,而不是只凭肉眼看几个问答样例。

分块不是一次性参数配置,而是 RAG 系统里需要持续调优的索引设计问题。切得好,Embedding 才能表达正确语义;召回准,重排和大语言模型才有可靠输入。把知识按自然结构和语义边界交给模型,通常比盲目更换向量模型更能稳定改善问答质量。


评论