乐于分享
好东西不私藏

RAG优化思路(一)——文档预处理与分块总结

RAG优化思路(一)——文档预处理与分块总结

点击下方卡片,关注“人工智能陈小白

视觉/大模型/图像重磅干货,第一时间送达!

一、技术栈总览

模块
选型
用途
PDF结构化解析
MinerU (magic-pdf)
目录、表格、标题层级提取,输出标准Markdown
PDF精细化处理
PyMuPDF (fitz)
文本坐标提取、坐标级降噪
扫描件识别
PaddleOCR
扫描版PDF图文识别
文本分块
LangChain RecursiveCharacterTextSplitter
递归字符语义分块
基础工具
hashlib、re
内容哈希计算、正则规则清洗

依赖安装命令:

pip install pymupdf langchain-text-splitters magic-pdf paddleocr

二、PDF 解析质量差、噪声污染严重

2.1 问题描述

  1. 1. 扫描版PDF无法提取有效文本;
  2. 2. 原生PDF的页眉、页脚、页码、水印、重复版权声明等无效内容混入正文分片,稀释语义,干扰向量检索匹配;
  3. 3. PDF按版面宽度强制换行,导致完整句子被拆分为多行,分块时易从中间切断语义。

2.2 现象示例

# 原始解析输出(带噪声+换行错乱)
XX设备 V2.0 使用手册
3.2 设备运行规范
本设备在常温环境下连续工作时长
不得超过8小时,超过后需要停机
散热30分钟方可再次启动。
第 3 页 共 45 页 | 版权所有 XX公司 2025

无效页眉页脚占比超30%,核心语句被拆分为3行,分块时易出现语义断裂。

2.3 技术方案

采用「三层降噪 + 格式归一」流水线:

  1. 1. 工具层选型:结构化文档优先使用MinerU输出标准Markdown;复杂版式用PyMuPDF提取坐标做精细化处理;扫描件接入PaddleOCR
  2. 2. 坐标级降噪:基于文本块的页面坐标,批量剔除固定位置的页眉、页脚、水印区域。
  3. 3. 规则级降噪:通过正则匹配固定格式的页码、版权声明、保密提示等重复无效文本,统一移除。
  4. 4. 格式归一化:合并被版面强制换行打断的句子,统一冗余空格与换行符,同时保留原生段落边界。

2.4 代码实现

import fitz  # PyMuPDF
import re
from typing importList

defclean_pdf_text(
    pdf_path: str,
    header_y_threshold: float = 70.0,
    footer_y_threshold: float = 780.0
) -> str:
"""
    PDF文本提取 + 坐标降噪 + 格式归一化处理
    :param pdf_path: PDF文件路径
    :param header_y_threshold: 页眉y坐标阈值,小于该值判定为页眉区域
    :param footer_y_threshold: 页脚y坐标阈值,大于该值判定为页脚区域
    :return: 清洗后的完整文本
    """

    doc = fitz.open(pdf_path)
    cleaned_pages: List[str] = []

# 正则规则:匹配页码、版权、保密声明等无效文本
    page_num_pattern = re.compile(r"第\s*\d+\s*页\s*共\s*\d+\s*页")
    copyright_pattern = re.compile(r"版权所有.*?\d{4}")
    secret_pattern = re.compile(r"保密声明|内部资料|严禁外传")

for page in doc:
# 获取页面所有文本块,格式:(x0, y0, x1, y1, text, block_no, block_type)
        blocks = page.get_text("blocks")
        valid_lines = []

for block in blocks:
            x0, y0, x1, y1, text, block_no, block_type = block
# 跳过非文本块、页眉区域、页脚区域
if block_type != 0:
continue
if y0 < header_y_threshold or y1 > footer_y_threshold:
continue

# 逐行正则清洗无效内容
            text = page_num_pattern.sub("", text)
            text = copyright_pattern.sub("", text)
            text = secret_pattern.sub("", text)

# 去除首尾空白
            text = text.strip()
if text:
                valid_lines.append(text)

# 页面内格式归一:合并断句、统一空格
        page_text = " ".join(valid_lines)
        page_text = re.sub(r"\s+"" ", page_text)  # 合并多余空格
        cleaned_pages.append(page_text)

    doc.close()
# 页面间保留双换行作为段落边界
return"\n\n".join(cleaned_pages)

if __name__ == "__main__":
# 调用示例
    result = clean_pdf_text("product_manual_v2.0.pdf")
print(result)

处理后输出效果

3.2 设备运行规范
本设备在常温环境下连续工作时长不得超过8小时,超过后需要停机散热30分钟方可再次启动。

三、表格结构丢失,模型无法正确解读

3.1 问题描述

普通文本提取工具将表格退化为零散纯文本,行列逻辑关系断裂,表头与数据行错位;分块时若从表格中间截断,会出现数据对应关系完全错误,大模型无法正确解读表格数据含义。

3.2 现象示例

普通工具解析产品参数表后,输出无结构纯文本,行列对应关系完全丢失:

# 普通工具解析结果(无结构纯文本)
参数名称 参数值 单位
额定电压 220 V
额定功率 1500 W
工作温度 -10~60 ℃

分块若从第二行截断,会出现「额定功率 单位 V」的错位错误,模型直接理解偏差。分块从中间截断后的数据错位效果,表头与数值错配,模型会直接读取到错误的对应关系。

3.3 技术方案

  1. 1. 解析层:使用MinerU结构化提取表格,原生输出Markdown格式,完整保留表头、行列对应关系。
  2. 2. 分块策略:单张表格作为独立父块,不强制截断;超长表格按行拆分,每段子块均携带表头信息,保证任意片段被召回都可独立解读。
  3. 3. 检索优化:为每张表格生成一句话语义摘要作为子块,用于向量检索;命中后返回完整表格父块作为上下文。
    上方为结构化解析输出的标准表格,表头、行列、单元格边界清晰,语义完整;下方为超长表格拆分后的效果,每个子片段都重复携带表头,即使只召回其中一段,模型也能完整理解数据含义,不会出现错位。

3.4 代码实现

import re
from typing importList

defsplit_long_table(markdown_table: str, max_rows_per_chunk: int = 20) -> List[str]:
"""
    超长Markdown表格拆分,每个子片段均携带表头
    :param markdown_table: 完整Markdown表格文本
    :param max_rows_per_chunk: 每个子块最大数据行数
    :return: 拆分后的带表头表格片段列表
    """

    lines = markdown_table.strip().split("\n")
iflen(lines) < 3:
return [markdown_table]

# 提取表头与分隔线
    header_line = lines[0]
    separator_line = lines[1]
    data_lines = lines[2:]

    chunks = []
for i inrange(0len(data_lines), max_rows_per_chunk):
        batch_data = data_lines[i:i + max_rows_per_chunk]
        chunk_table = "\n".join([header_line, separator_line] + batch_data)
        chunks.append(chunk_table)

return chunks

defgenerate_table_summary(table_markdown: str, table_title: str = "") -> str:
"""
    生成表格语义摘要(用于子块向量检索)
    :param table_markdown: Markdown表格
    :param table_title: 表格标题
    :return: 摘要文本
    """

    lines = table_markdown.strip().split("\n")
# 提取表头字段
    headers = [h.strip() for h in lines[0].strip("|").split("|")]
    data_rows = len(lines) - 2# 减去表头和分隔线

if table_title:
returnf"{table_title},包含字段:{', '.join(headers)},共{data_rows}条数据。"
returnf"参数数据表,包含字段:{', '.join(headers)},共{data_rows}条数据。"

if __name__ == "__main__":
# 调用示例
    sample_table = """
| 参数名称 | 参数值 | 单位 |
| -------- | ------ | ---- |
| 额定电压 | 220    | V    |
| 额定功率 | 1500   | W    |
| 工作温度 | -10~60 | ℃    |
| 整机重量 | 12.5   | kg   |
    """
.strip()

# 拆分超长表格
    table_chunks = split_long_table(sample_table, max_rows_per_chunk=2)
print("拆分后表格片段:")
for idx, chunk inenumerate(table_chunks):
print(f"片段{idx + 1}:\n{chunk}\n")

# 生成摘要
    summary = generate_table_summary(sample_table, table_title="设备核心参数表")
print(f"表格摘要子块:{summary}")

输出效果

拆分后表格片段:
片段1:
| 参数名称 | 参数值 | 单位 |
| -------- | ------ | ---- |
| 额定电压 | 220    | V    |
| 额定功率 | 1500   | W    |

片段2:
| 参数名称 | 参数值 | 单位 |
| -------- | ------ | ---- |
| 工作温度 | -10~60 | ℃    |
| 整机重量 | 12.5   | kg   |

表格摘要子块:设备核心参数表,包含字段:参数名称, 参数值, 单位,共4条数据。

四、分块边界漂移,无法实现精细增量更新

4.1 问题描述

固定长度滑动分块、全局递归分块模式下,文档中间内容修改后,后续所有分片的起止位置整体偏移,新旧分片无法对齐;无法精准定位变更片段,只能整篇文档重新分块向量化,造成算力浪费,也无法实现章节级增量更新。

4.2 现象示例

分块边界漂移的本质是:全局连续分块模式下,局部内容长度变化会沿文本向后传导,导致后续所有分片的起止位置全部偏移,最终表现为“改一行,全量变”。

直观对比:修改前后的全局分块变化

【修改前:全局固定长度分块】
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ Chunk 1 │ Chunk 2 │ Chunk 3 │ Chunk 4 │ Chunk 5 │
│ 第1章   │ 第2章   │ 第3章   │ 第3~4章 │ 第5章   │
│ 内容不变 │ 内容不变 │ 前半段  │ 后半段  │ 内容不变 │
└─────────┴─────────┴─────────┴─────────┴─────────┘
  哈希A     哈希B     哈希C     哈希D     哈希E

【修改后:在第3章开头插入2行文字】
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ Chunk 1 │ Chunk 2 │ Chunk 3 │ Chunk 4 │ Chunk 5 │
│ 第1章   │ 第2章   │ 第3章   │ 第3章   │ 第4~5章 │
│ 内容不变 │ 前半段  │ 新增内容 │ 原后半段│ 后移内容 │
└─────────┴─────────┴─────────┴─────────┴─────────┘
  哈希A     哈希B'    哈希C'    哈希D'    哈希E'

漂移带来的实际后果

  1. 1. 分片全量失效:从Chunk 2开始,后续所有Chunk的内容都发生了变化,哈希值全部改变;系统无法区分哪些是真实修改、哪些只是位置偏移,只能判定为全部变更。
  2. 2. 算力严重浪费:原本只改了2行文字,却要重新计算几十个分片的Embedding,API调用成本、计算耗时成倍增加。
  3. 3. 无法做精细增量:只能整篇文档全量删除再全量写入,更新耗时长,且无法实现章节级的版本管理与回滚。

4.3 技术方案

采用双层切分架构,用章节作为天然的“漂移隔离墙”,彻底阻断局部修改向后传导的路径,实现章节级精准增量更新。

4.3.1 架构分层设计

整个分块流程分为两层,上层负责稳定边界,下层负责语义分片,两层职责完全解耦:

层级
切分依据
核心作用
稳定性
第一层:章节父块层
Markdown标题层级 / PDF目录大纲
划定稳定边界,作为增量更新最小单元
极高,章节编号不变则边界不变
第二层:子块分片层
递归字符分块(仅在章节内执行)
控制分片长度,适配向量检索精度
仅章节内部波动,不向外扩散

4.3.2 稳定边界核心设计

  1. 1. 稳定 chapter_id 生成规则
    不依赖章节标题文字生成ID,采用「文档ID + 层级序号」的规则,示例:
    chapter_id = {doc_id}_ch{一级序号}_{二级序号}
    示例:prod_manual_v2_ch3_1  → 产品手册V2第3章第1节
    仅修改章节标题文字,不会导致chapter_id变化,章节边界始终稳定。
  2. 2. 内容哈希校验机制
    每个章节独立计算SHA256内容哈希(仅对比章节正文,不包含标题):
    • • 哈希一致 → 章节未修改,直接复用所有历史子块与向量;
    • • 哈希不一致 → 章节内容变更,仅重新生成本章节内的子块与向量。

4.3.3 完整增量更新执行流程

  1. 1. 新版本文档解析:上传新文档,解析出全部章节,生成对应chapter_id与内容哈希;
  2. 2. 逐章对比校验:和数据库中历史版本的同chapter_id做哈希比对;
  3. 3. 分类处理
    • • 无变更章节:跳过所有处理,直接复用历史子块、向量、元数据;
    • • 变更章节:标记旧子块软删除,在本章内部重新执行递归分块 → 生成新子块 → 计算Embedding → 写入向量库;
    • • 新增章节:新建chapter_id,执行完整分块与向量化;
    • • 删除章节:标记对应chapter_id下所有子块软删除;
  4. 4. 原子生效:所有处理完成后,统一更新文档版本号,新内容对外可见。

4.3.4 为什么能彻底解决漂移

【双层切分架构:修改第3章后的效果】
┌─────────┬─────────┬─────────┬─────────┐
│ 第1章   │ 第2章   │ 第3章   │ 第4章   │
│ 父块不变 │ 父块不变 │ 父块变更 │ 父块不变 │
│ 子块全复用│ 子块全复用│ 子块重算 │ 子块全复用│
└─────────┴─────────┴─────────┴─────────┘

章节与章节之间是完全独立的硬边界,第3章内部的内容长度变化,只会影响第3章内部的子块位置,不会传导到第4章及之后的章节;漂移被严格限制在单个章节内部,外部边界完全稳定。

4.3.5 边界场景处理

  1. 1. 章节标题修改:chapter_id基于序号生成,标题修改不触发ID重建,仅更新标题元数据,不触发重向量化。
  2. 2. 章节顺序调整:按序号重新匹配chapter_id,顺序变化的章节若内容未变,仅更新排序字段,不触发重向量化。
  3. 3. 章节拆分/合并:视为新增/删除章节,对应重建子块;属于结构性变更,本身就需要重新处理,不属于漂移问题。

4.4 代码实现

import re
import hashlib
from langchain_text_splitters import RecursiveCharacterTextSplitter
from typing importListDict

defsplit_markdown_chapters(markdown_text: str, doc_id: str) -> List[Dict]:
"""
    第一层:按Markdown二级标题拆分章节,生成稳定chapter_id与内容哈希
    :param markdown_text: 完整Markdown文档文本
    :param doc_id: 文档唯一ID
    :return: 章节列表,每个章节包含chapter_id、标题、内容、哈希
    """

# 匹配二级标题作为章节边界(可根据文档层级调整为#/###)
    chapter_pattern = re.compile(r'^##\s+(.+)$', re.MULTILINE)
    matches = list(chapter_pattern.finditer(markdown_text))

    chapters = []
for idx, matchinenumerate(matches):
        chapter_title = match.group(1).strip()
        start_pos = match.end()
        end_pos = matches[idx + 1].start() if idx + 1 < len(matches) elselen(markdown_text)
        chapter_content = markdown_text[start_pos:end_pos].strip()

# 生成稳定chapter_id:文档ID + 章节序号,不依赖标题文字
        chapter_id = f"{doc_id}_ch{idx + 1:02d}"
# 计算章节内容哈希(用于增量对比)
        content_hash = hashlib.sha256(chapter_content.encode("utf-8")).hexdigest()

        chapters.append({
"chapter_id": chapter_id,
"chapter_title": chapter_title,
"content": chapter_content,
"content_hash": content_hash
        })

return chapters

defsplit_chapter_to_chunks(
    chapter: Dict,
    chunk_size: int = 500,
    chunk_overlap: int = 50
) -> List[Dict]:
"""
    第二层:单个章节内部执行递归分块,子块挂载chapter_id
    :param chapter: 章节字典
    :param chunk_size: 分块大小(字符数)
    :param chunk_overlap: 重叠大小
    :return: 子块列表
    """

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n""\n""。""!""?"" """]
    )

    chunk_texts = splitter.split_text(chapter["content"])
    chunks = []

for idx, text inenumerate(chunk_texts):
        chunk_id = f"{chapter['chapter_id']}_chunk{idx:03d}"
        chunks.append({
"chunk_id": chunk_id,
"chapter_id": chapter["chapter_id"],
"content": text,
"parent_content": chapter["content"]  # 父块完整内容,用于父子块RAG
        })

return chunks

if __name__ == "__main__":
# 调用示例
    sample_doc = """
# XX设备使用手册

## 3.1 核心参数
设备额定电压220V,额定功率1500W,工作温度范围-10~60℃。
整机重量12.5kg,防护等级IP54。

## 3.2 运行规范
本设备在常温环境下连续工作时长不得超过8小时。
超过8小时后需要停机散热30分钟方可再次启动。
    """
.strip()

# 第一层:拆分章节
    chapters = split_markdown_chapters(sample_doc, doc_id="prod_001")
print("章节拆分结果:")
for ch in chapters:
print(f"ID: {ch['chapter_id']}, 标题: {ch['chapter_title']}, 哈希前缀: {ch['content_hash'][:8]}...")

# 第二层:章节内分块
    all_chunks = []
for ch in chapters:
        chunks = split_chapter_to_chunks(ch)
        all_chunks.extend(chunks)

print("\n分块结果:")
for ck in all_chunks:
print(f"子块ID: {ck['chunk_id']}, 归属章节: {ck['chapter_id']}")

核心特性:章节ID仅与文档ID、章节序号绑定,修改章节标题文字不会导致ID失效;章节内部修改不会影响其他章节的分片边界,完美支持章节级增量更新。


五、父子块架构重复召回,Token 浪费严重

5.1 问题描述

父子块(Parent-Child)RAG 的核心逻辑是「细粒度子块做语义检索、粗粒度父块做上下文补全」,以此兼顾召回精度与信息完整性。但在实际运行中,同一父章节下通常包含多个语义相近的子块,用户查询很容易同时命中同一章节的多个子块。
如果检索后直接按照子块结果逐份加载对应的完整父章节文本,会导致同一份父章节内容被重复送入LLM上下文,带来三类核心问题:

  1. 1. Token 成本成倍浪费:重复内容无效消耗大模型Token额度,章节内命中子块越多,冗余占比越高;
  2. 2. 有效信息密度下降:重复内容挤占上下文窗口,导致其他相关章节的有效信息无法被送入,反而降低回答质量;
  3. 3. 模型输出偏差:大模型对重复出现的内容注意力权重更高,容易出现内容重复赘述、逻辑冗余的问题,甚至因重复信息干扰产生错误结论。

5.2 现象示例

直观流程对比

【未做聚合去重:错误流程】
用户Query → 向量检索返回3个高相似子块
      ├─ 子块3-1(归属第3章)→ 加载完整第3章父文本(500字)
      ├─ 子块3-2(归属第3章)→ 加载完整第3章父文本(500字)
      └─ 子块3-3(归属第3章)→ 加载完整第3章父文本(500字)
                ↓
      送入LLM的上下文:第3章内容 × 3份 = 1500字
      冗余占比:67%,有效信息仅1/3

【聚合去重后:正确流程】
用户Query → 向量检索返回3个高相似子块
      ├─ 子块3-1(归属第3章)
      ├─ 子块3-2(归属第3章)→ 按chapter_id聚合 → 仅保留1份第3章父文本(500字)
      └─ 子块3-3(归属第3章)
                ↓
      送入LLM的上下文:第3章内容 × 1份 = 500字
      零冗余,有效信息密度100%

量化影响示例

以产品手册场景为例,单个章节父块平均长度约500字符,一次查询平均召回8个子块,其中5个属于同一章节:

  • • 未去重:总上下文长度约 5×500 + 3×500 = 4000 字符,其中2000字符为重复内容,Token浪费率达50%;
  • • 去重后:总上下文长度约 1×500 + 3×500 = 2000 字符,无重复冗余,Token消耗直接降低50%。

同时重复内容会导致LLM输出时反复赘述同一段内容,回答啰嗦、逻辑不凝练,用户体验下降。

5.3 技术方案

检索链路中新增子块召回 → 聚合去重 → 排序截断的标准化处理环节,在保证检索效果的前提下,彻底消除父块重复冗余。

5.3.1 核心聚合去重逻辑

以 doc_id + chapter_id 作为唯一聚合键,对召回的所有子块做分组聚合,同一章节仅保留一份完整父块文本,同时聚合保留关键统计信息:

  1. 1. 基础信息:章节唯一ID、章节标题、完整父块正文;
  2. 2. 相似度指标:该章节下所有命中子块的最高相似度分数;
  3. 3. 命中统计:该章节下被命中的子块数量。

聚合键必须包含doc_id,避免不同文档中出现相同章节编号时,错误地跨文档合并内容。

5.3.2 父块综合排序策略

去重后的父块不再按单一相似度排序,采用「最高相似度 + 命中次数」加权融合的综合分排序,更精准地体现章节整体相关性:

  • • 权重配置:最高相似度占70%,命中次数归一化占30%;
  • • 计算公式:综合分 = 最高相似度 × 0.7 + (命中子块数 / 总召回数) × 0.3
  • • 排序规则:按综合分降序排列,相关性越高的章节越靠前,匹配LLM「首尾注意力更强」的特性,优化回答质量。

5.3.3 上下文长度管控

去重后对父块总长度做二次校验,避免超出LLM上下文窗口限制,同时保证有效信息密度:

  1. 1. 设置总长度阈值:默认不超过模型最大上下文窗口的40%,预留足够空间给系统提示词、对话历史与生成内容;
  2. 2. 超长截断策略:总长度超阈值时,按综合分从低到高依次移除章节,直到满足长度要求;
  3. 3. 单章超长处理:单个父块长度超出阈值时,优先保留命中子块附近的上下文段落,截断非相关区域,而非直接丢弃整章。

5.3.4 与重排链路的协同方案

针对同时启用重排模型的链路,采用「子块重排 → 聚合去重 → 父块二次排序」的顺序,兼顾精度与效率:

  1. 1. 先对细粒度子块做重排序,利用重排模型的精准语义判断能力,筛选高相关子块;
  2. 2. 再对重排后的子块做章节聚合去重,避免重复加载父块;
  3. 3. 最后基于重排后的子块分数,计算父块综合分并排序。

禁止先聚合去重再重排:父块粒度太粗,重排模型无法精准判断语义相关性,会显著降低重排效果。

5.3.5 边界场景处理

  1. 1. 单章节全量命中:若某章节下所有子块都被召回,依然只保留一份父块,同时提升该章节的排序权重;
  2. 2. 跨文档同名章节:通过doc_id + chapter_id双维度聚合,杜绝不同文档的章节错误合并;
  3. 3. 子块跨章节边界:在双层切分架构下,子块严格禁止跨章节生成,因此不会出现一个子块归属多个父块的情况,聚合逻辑无歧义。

5.4 代码实现

from typing importListDict

defdeduplicate_parent_chunks(retrieved_chunks: List[Dict]) -> List[Dict]:
"""
    父子块召回结果聚合去重,按chapter_id合并,返回去重后的父块列表
    :param retrieved_chunks: 检索返回的子块列表,每个子块需包含chapter_id、parent_content、similarity
    :return: 去重后的父块列表,按相似度降序排序
    """

    chapter_map = {}

for chunk in retrieved_chunks:
        chapter_id = chunk["chapter_id"]
if chapter_id notin chapter_map:
# 首次出现该章节,初始化记录
            chapter_map[chapter_id] = {
"chapter_id": chapter_id,
"parent_content": chunk["parent_content"],
"max_similarity": chunk["similarity"],
"hit_count"1
            }
else:
# 更新最高相似度、命中次数
            chapter_map[chapter_id]["hit_count"] += 1
if chunk["similarity"] > chapter_map[chapter_id]["max_similarity"]:
                chapter_map[chapter_id]["max_similarity"] = chunk["similarity"]

# 按最高相似度降序排序
    sorted_chapters = sorted(
        chapter_map.values(),
        key=lambda x: x["max_similarity"],
        reverse=True
    )

return sorted_chapters

if __name__ == "__main__":
# 模拟检索返回的子块
    mock_retrieved = [
        {"chapter_id""prod_001_ch03""parent_content""第3章完整内容...""similarity"0.89},
        {"chapter_id""prod_001_ch03""parent_content""第3章完整内容...""similarity"0.82},
        {"chapter_id""prod_001_ch03""parent_content""第3章完整内容...""similarity"0.78},
        {"chapter_id""prod_001_ch05""parent_content""第5章完整内容...""similarity"0.75},
    ]

    deduplicated = deduplicate_parent_chunks(mock_retrieved)
print("去重后父块列表:")
for item in deduplicated:
print(f"章节: {item['chapter_id']}, 最高相似度: {item['max_similarity']}, 命中次数: {item['hit_count']}")

输出效果

去重后父块列表:
章节: prod_001_ch03, 最高相似度: 0.89, 命中次数: 3
章节: prod_001_ch05, 最高相似度: 0.75, 命中次数: 1

3个同章节子块合并为1个父块,Token消耗降低67%。


六、代码块、参数列表被强制截断

6.1 问题描述

固定长度、固定字符数的通用分块策略,会无视代码、命令、配置的语法边界,从语义单元中间强制截断,直接破坏内容的语法完整性与逻辑完整性。在RAG场景下,该问题会引发四层连锁危害:

  1. 1. 执行失效:Shell命令、配置指令被截断后语法残缺,用户直接复制执行会报错,甚至产生错误配置引发线上故障;
  2. 2. 逻辑断裂:函数、类、条件分支被拦腰切断,代码逻辑不闭合,大模型无法理解完整逻辑,甚至基于残缺代码给出错误的调试与优化方案;
  3. 3. 参数错位:键值对、参数说明被拆分到不同分片,参数名与参数解释、默认值错位,模型解读参数含义时出现偏差;
  4. 4. 次生幻觉:大模型接收到不完整的代码/命令片段时,可能自行补全缺失的后半段内容,生成与原文不符的虚假代码,用户难以甄别。

6.2 现象示例

直观对比:完整单元 vs 强制截断效果

【完整语义单元(正确)】          【固定长度强制截断(错误)】
┌───────────────────────┐        ┌───────────────────────┐
│ def calculate_power():│        │ def calculate_power():│
│     # 计算额定功率    │        │     # 计算额定功率    │
│     power = v * i     │  ───►  │     power = v *      │
│     return power      │        ├───────────────────────┤
└───────────────────────┘        │ i                     │
  语法完整,逻辑自洽              │     return power      │
                                 └───────────────────────┘
                                    语法断裂,逻辑拆分
                                    模型易误读、不可直接运行

典型场景1:Shell命令截断

# 被截断的配置命令
# 设备启动配置
systemctl start device-service
# 设置开机自启
systemctl enable

后半句服务名device-service被切到下一个Chunk,命令不完整,直接执行会报错,用户无法通过检索结果完成配置操作。

典型场景2:代码函数截断

# 被截断的Python函数
def calculate_power(voltage, current):
    # 计算额定功率
    power = voltage * current
    # 功率校验
    if power >

函数体、条件分支未闭合,语法完全失效;模型无法判断校验阈值与后续逻辑,只能基于残缺内容推断,极易输出错误结论。

典型场景3:配置文件截断

# 被截断的YAML配置
service:
  name: device-agent
  port: 8080
  log:
    level: info
    path: /var/

日志路径值被截断,配置项不完整,直接使用会导致服务启动失败,甚至写入错误路径引发权限问题。

6.3 技术方案

核心思路是前置识别 + 优先级保护 + 语义化拆分:先标记所有不可拆分的语义单元,分块时优先保护其完整性;超长单元按语法边界拆分,杜绝字符级硬切。

6.3.1 不可拆分语义单元识别体系

预扫描全文,基于语法规则识别四类高优先级保护单元,标记起止位置与类型,作为分块的“硬边界”:

单元类型
识别规则
保护优先级
Markdown代码块
开头、结尾的整块内容,兼容带语言标识的格式(python、bash)
最高
连续列表单元
有序列表(数字. 开头)、无序列表(-/* 开头)的连续行,同属一个列表的完整条目组
参数配置块
YAML/JSON/INI格式的配置段落、键值对参数表,保持缩进层级完整
命令行序列
连续的Shell命令、带注释的命令组,命令与对应说明绑定

识别方式采用「正则匹配边界 + 缩进层级校验」,确保标记的单元边界准确,无遗漏、无误判。

6.3.2 分块优先级保护机制

改造通用分块流程,从“全局一刀切”改为「先划保护边界,再切割普通文本」,完整流程如下:

  1. 1. 预扫描标记:遍历全文,识别所有不可拆分单元,生成带起止坐标的保护块列表,按位置排序;
  2. 2. 文本分段:以保护块为天然分隔点,将全文切割为「普通文本段」与「保护块」交替的片段;
  3. 3. 普通文本分块:对普通文本段执行常规递归字符分块,遵守预设的chunk_size与overlap;
  4. 4. 保护块整段保留:所有标记的保护块不做强制截断,整体作为一个独立Chunk;
  5. 5. 碎片合并:若普通文本段过短(低于最小长度阈值),与相邻保护块合并为一个Chunk,避免产生语义稀疏的碎片。

6.3.3 超长保护块的语义化拆分策略

当保护块长度远超分块阈值时(如数百行的代码文件、超长配置清单),不做字符长度硬切,严格按语法/语义边界拆分,保证每一段都语法完整、逻辑自洽:

  • • 代码块拆分:按函数、类、模块级注释为边界拆分,每个拆分单元都是一个可独立理解的完整函数/类;拆分后为每个子块补充上下文说明,如「以下为设备计算模块的功率校验函数,隶属于设备核心服务代码」。
  • • 配置块拆分:按一级配置项拆分,每个单元保留完整的缩进层级与键值对,确保单段配置可独立解读。
  • • 长列表拆分:按条目组拆分,每组保留列表的总说明与上下文,避免单条目语义稀疏无法召回。

6.3.4 元数据标记与检索适配

  1. 1. 块类型标记:每个Chunk的元数据中新增block_type字段,枚举值为normal/code/table/list/config,标识内容类型;
  2. 2. 命中完整性校验:检索命中代码/配置类Chunk时,自动校验是否为拆分后的子片段;若是则自动关联加载同属一个保护单元的所有相邻片段,保证返回给LLM与用户的内容完整。
  3. 3. 生成约束:系统Prompt中增加规则,要求大模型引用代码、命令、配置时必须完整复用原文内容,禁止自行补全缺失部分;若上下文内容不完整,明确说明内容不完整,不编造补全。

6.3.5 核心代码实现

import re
from typing importListTupleDict

defextract_all_protected_blocks(text: str) -> List[Dict]:
"""
    全类型识别不可拆分语义单元,返回带位置、类型的保护块列表
    :param text: 原始文本
    :return: 保护块列表,包含start、end、block_type、content
    """

    blocks = []

# 1. 识别Markdown代码块(最高优先级)
    code_pattern = re.compile(r'```[\w]*\n(.*?)```', re.DOTALL)
formatchin code_pattern.finditer(text):
        blocks.append({
"start"match.start(),
"end"match.end(),
"block_type""code",
"content"match.group(0)
        })

# 2. 识别连续有序列表
    ordered_list_pattern = re.compile(r'((?:^\d+\.\s+.+$\n?)+)', re.MULTILINE)
formatchin ordered_list_pattern.finditer(text):
# 排除已被代码块覆盖的区域
ifnotany(b["start"] <= match.start() and b["end"] >= match.end() for b in blocks):
            blocks.append({
"start"match.start(),
"end"match.end(),
"block_type""ordered_list",
"content"match.group(0)
            })

# 3. 识别连续无序列表
    unordered_list_pattern = re.compile(r'((?:^[-*]\s+.+$\n?)+)', re.MULTILINE)
formatchin unordered_list_pattern.finditer(text):
ifnotany(b["start"] <= match.start() and b["end"] >= match.end() for b in blocks):
            blocks.append({
"start"match.start(),
"end"match.end(),
"block_type""unordered_list",
"content"match.group(0)
            })

# 按起始位置排序
    blocks.sort(key=lambda x: x["start"])
return blocks

defsmart_split_with_protection(text: str, chunk_size: int = 500, min_chunk_size: int = 100) -> List[str]:
"""
    带保护单元的智能分块:保护完整语义单元,普通文本按长度拆分
    :param text: 原始文本
    :param chunk_size: 普通文本分块大小
    :param min_chunk_size: 最小分片长度,低于该值则与相邻块合并
    :return: 分块结果
    """

    protected_blocks = extract_all_protected_blocks(text)
    chunks = []
    last_pos = 0

for block in protected_blocks:
# 处理保护块之前的普通文本
        normal_text = text[last_pos:block["start"]].strip()
if normal_text:
# 普通文本简易分块(生产环境可接入LangChain递归分块)
iflen(normal_text) > chunk_size:
# 按段落拆分
                paragraphs = normal_text.split("\n\n")
                temp = ""
for para in paragraphs:
iflen(temp) + len(para) < chunk_size:
                        temp += para + "\n\n"
else:
if temp.strip():
                            chunks.append(temp.strip())
                        temp = para + "\n\n"
if temp.strip():
                    chunks.append(temp.strip())
else:
                chunks.append(normal_text)

# 保护块整体保留
        chunks.append(block["content"].strip())
        last_pos = block["end"]

# 处理末尾普通文本
    tail_text = text[last_pos:].strip()
if tail_text:
        chunks.append(tail_text)

# 碎片合并:过短的分片与前一个合并
    merged_chunks = []
for chunk in chunks:
if merged_chunks andlen(chunk) < min_chunk_size:
            merged_chunks[-1] = merged_chunks[-1] + "\n\n" + chunk
else:
            merged_chunks.append(chunk)

return merged_chunks

if __name__ == "__main__":
    sample_text = """
### 4.1 服务部署
执行以下命令完成设备服务安装与启动:

```bash
# 安装依赖包
yum install -y device-lib openssl-devel
# 启动核心服务
systemctl start device-service
# 设置开机自启
systemctl enable device-service
# 验证服务状态
systemctl status device-service


部署完成后需完成三项检查:
1. 检查8080端口监听状态
2. 查看服务日志无报错
3. 调用健康检查接口验证返回值

配置说明
服务配置文件默认路径为/etc/device/config.yaml,核心配置项如下:
```yaml
service:
  name: device-agent
  port: 8080
  mode: production
log:
  level: info
  path: /var/log/device/
  max_size: 100M

    """
.strip()

    chunks = smart_split_with_protection(sample_text)
print(f"总分块数:{len(chunks)}")
for idx, chunk inenumerate(chunks):
print(f"\n--- 分块{idx+1}(长度:{len(chunk)})---")
print(chunk)

6.3.6 边界场景与兜底处理

  1. 1. 行内代码:单重反引号包裹的行内代码不属于保护单元,随普通文本正常分块,不会影响语义完整性,无需特殊处理;
  2. 2. 嵌套代码块:优先匹配最外层的```边界,保证整块完整,不拆分嵌套结构;
  3. 3. 格式不规范的代码:未用```包裹的代码段,通过「缩进+关键字+行号特征」识别,降级为疑似保护块,优先保留完整,避免误截断;
  4. 4. 极端超长代码:超过单章长度的完整代码文件,关联章节父块,检索命中后按需返回对应片段,同时提供完整代码的跳转入口,避免上下文超限。

6.4 代码实现

import re
from typing importListTuple

defextract_protected_blocks(text: str) -> List[Tuple[intintstr]]:
"""
    提取文本中需要保护的不可拆分单元(代码块、有序列表)
    :param text: 原始文本
    :return: 保护块列表,每个元素为(起始位置, 结束位置, 块类型)
    """

    protected = []

# 匹配Markdown代码块(```包裹)
    code_pattern = re.compile(r'```.*?\n(.*?)```', re.DOTALL)
formatchin code_pattern.finditer(text):
        protected.append((match.start(), match.end(), "code_block"))

# 匹配连续有序列表(数字开头)
    list_pattern = re.compile(r'((?:^\d+\.\s+.+$\n?)+)', re.MULTILINE)
formatchin list_pattern.finditer(text):
        protected.append((match.start(), match.end(), "ordered_list"))

# 按起始位置排序
    protected.sort(key=lambda x: x[0])
return protected

defsplit_with_protected_blocks(text: str, chunk_size: int = 500) -> List[str]:
"""
    带保护单元的分块:保护完整代码块、列表,普通文本按语义拆分
    :param text: 原始文本
    :param chunk_size: 普通文本分块大小
    :return: 分块结果
    """

    protected_blocks = extract_protected_blocks(text)
    chunks = []
    last_pos = 0

for start, end, block_type in protected_blocks:
# 处理保护块之前的普通文本
if start > last_pos:
            normal_text = text[last_pos:start].strip()
if normal_text:
                chunks.append(normal_text)

# 完整保留保护块
        protected_content = text[start:end].strip()
if protected_content:
            chunks.append(protected_content)

        last_pos = end

# 处理最后一段普通文本
if last_pos < len(text):
        tail_text = text[last_pos:].strip()
if tail_text:
            chunks.append(tail_text)

return chunks

if __name__ == "__main__":
# 调用示例
    sample_text = """
### 4.1 服务部署
执行以下命令安装并启动设备服务:

```bash
# 安装依赖
yum install -y device-lib
# 启动服务
systemctl start device-service
# 设置开机自启
systemctl enable device-service
部署完成后需要验证服务状态,检查端口监听情况,确认日志无报错。
    """
.strip()

    chunks = split_with_protected_blocks(sample_text)
print("分块结果:")
for idx, chunk inenumerate(chunks):
print(f"--- 块{idx + 1} ---\n{chunk}\n")



**输出效果**:代码块被完整保留,不会从中间截断,保证语法与语义完整性。

--- 块1 ---
### 4.1 服务部署
执行以下命令安装并启动设备服务:

--- 块2 ---
```bash
# 安装依赖
yum install -y device-lib
# 启动服务
systemctl start device-service
# 设置开机自启
systemctl enable device-service
--- 块3 ---
部署完成后需要验证服务状态,检查端口监听情况,确认日志无报错。

七、章节长短严重不均,检索效果失衡

7.1 问题描述

产品手册、制度规范类文档受内容属性影响,天然存在章节篇幅两极分化的问题:原理说明、故障排查类章节动辄数千字,而安全提示、术语定义、注意事项类章节仅一两句话。这种长度失衡会从检索和生成两个维度直接拉低RAG系统效果:

  1. 1. 超长章节的危害
    • • 语义稀释:单块承载过多主题信息,向量表征过于宽泛,用户精准查询时匹配度偏低,召回精度下降;
    • • 上下文挤占:单章内容过长,送入LLM后占用大量上下文窗口,挤压其他相关章节的空间,甚至触发上下文超限;
    • • 答案定位难:模型需要在长文本中筛选有效信息,易出现漏答、错答,同时增加推理耗时与Token成本。
  2. 2. 超短章节的危害
    • • 语义特征稀疏:仅十几到几十字的内容,向量表征弱、区分度差,用户查询相关问题时相似度得分普遍偏低,很难被召回;
    • • 上下文不足:即便被命中,仅靠一两句话也无法支撑模型生成完整答案,容易产生幻觉。
  3. 3. 整体系统性影响
    长短不均会导致分块质量波动极大,检索效果不稳定:简单问题召回不到,复杂问题召回噪声多,整体回答质量的一致性无法保障。

7.2 现象示例

直观分布对比

【原始章节长度分布(失衡状态)】
┌─────────────────────────────────────┐  ┌─┐  ┌──┐  ┌───────────────────────────┐
│  故障排查(3000字,超长章节)       │  │安│  │术│  │  安装步骤(1800字)       │
│  包含30+故障场景、排查流程、解决方案│  │全│  │语│  │                           │
│  语义宽泛,向量匹配精度低           │  │提│  │定│  │                           │
│  单块挤占大量上下文窗口             │  │示│  │义│  │                           │
└─────────────────────────────────────┘  └─┘  └──┘  └───────────────────────────┘
                                         30字   80字
                                    语义稀疏,召回率极低

典型场景量化表现

  • • 超长章节案例:「设备故障排查」章节共3200字,涵盖硬件故障、软件报错、网络异常三大类共28个细分场景。用户查询「设备报错A01怎么解决」,向量检索时该章节因语义太宽泛,相似度得分仅0.62,排在多个无关短章节之后,未能进入TopK候选集,直接导致答案缺失。
  • • 超短章节案例:「安全警示」章节仅32字:「设备通电状态下禁止拆卸外壳,否则有触电风险。」因语义特征过于稀疏,用户查询「设备触电风险」「能不能带电拆外壳」等相关问题时,相似度均低于召回阈值,始终无法被命中,模型输出的答案遗漏了核心安全要求。

7.3 技术方案

核心设计原则:边界稳定优先,语义完整为辅,长度均衡为目标。所有调整均不破坏章节ID的稳定性,不影响章节级增量更新机制,在现有双层切分架构内完成长度优化。

7.3.1 长章节层级化拆分

优先按原生标题层级拆解超长章节,保留结构语义的同时控制单块长度,且完全兼容增量更新机制。

  1. 1. 拆分依据
    • • 第一优先级:按文档原生的三级、四级标题拆分,沿天然语义边界切割,不破坏内容逻辑;
    • • 第二优先级:无细分标题时,按独立语义段落、故障场景、参数分组等逻辑单元拆分,避免硬切。
  2. 2. 稳定ID规则
    拆分后的子章节继承父章节的基础编号,后缀追加子序号,保证ID永久稳定,示例:
    父章节ID:prod_001_ch05(第5章 故障排查)
    子章节ID:prod_001_ch05_sub01(5.1 硬件故障排查)
              prod_001_ch05_sub02(5.2 软件报错排查)
    父章节标题修改、其他子章节内容变更,均不会影响当前子章节的ID。
  3. 3. 边界约束
    • • 拆分禁止切断表格、代码块、完整列表等不可拆分语义单元;
    • • 每个子章节保留独立的内容哈希,作为增量更新的判断依据,修改单个子章节仅触发自身重向量化。
  4. 4. 检索联动
    子章节独立参与向量检索,命中后可按需返回子章节单独内容,或关联加载完整父章节上下文,兼顾精度与信息完整性。

7.3.2 短章节同主题聚合

对篇幅过短的相邻章节,按主题相关性做合并聚合,丰富语义特征,提升召回率。

  1. 1. 合并前置条件(需同时满足)
    • • 位置相邻:在原文档中为连续的同级小节;
    • • 主题相关:同属一个大的功能模块/知识分类,如安全类、参数类、注意事项类;
    • • 长度合规:合并后总长度不超过设定的父块长度上限。
  2. 2. ID与增量适配
    • • 合并后的聚合章节生成独立聚合ID,同时记录所有原始子章节的ID与内容哈希;
    • • 增量更新时,逐一校验原始子章节的哈希:任意一个子章节内容变更,重新生成聚合章节;未变更的子章节不触发重算;
    • • 禁止跨大章节合并,避免破坏文档的原生结构逻辑。
  3. 3. 效果增益
    同主题短章节合并后,语义特征更丰富,向量区分度显著提升。实测35个安全类短章节合并后,相关问题的召回率可提升25%40%。

7.3.3 阈值体系与动态校准

设置科学的长度上下限阈值,避免极端分片,同时支持按文档类型动态调优。

  1. 1. 推荐阈值基准(中文字符)
    文档类型
    推荐下限
    推荐上限
    适用场景
    产品手册/技术文档
    200字
    1200字
    章节结构清晰,兼顾精度与完整性
    制度规范/公文
    300字
    1500字
    长段落多,语义连贯性要求高
    FAQ/问答对
    100字
    500字
    内容短小,追求精准匹配

    通用场景默认值:下限200字,上限1200字,最优分块长度集中在500~800字区间。

  2. 2. 阈值调优方法
    • • 基于业务评测集做网格搜索,测试不同阈值下的召回率、答案准确率、Token消耗三项核心指标;
    • • 以「召回率+准确率综合得分最高,Token消耗可控」为标准,选定适配业务的最优阈值区间。
  3. 3. 震荡规避规则
    • • 章节长度与阈值相差10%以内时,不做拆分/合并操作,避免因微小改动触发频繁的结构调整;
    • • 优先保留原生章节结构,仅对极端超长、超短章节做处理,不追求绝对平均。

7.3.4 边界场景兜底处理

  1. 1. 图文混排章节:仅按纯文本长度计算阈值,图片、图表单独标记为独立语义单元,不纳入长度拆分的切割范围;
  2. 2. 单句超长章节:少数整段无换行的长文本,优先按语义标点(句号、分号)拆分,避免按字符硬切;
  3. 3. 结构性调整:当文档大版本升级、章节结构大幅变更时,触发一次全量长度均衡重算,日常小版本更新仅做增量校验。

7.4 代码实现

import hashlib
from typing importListDict

defbalance_chapter_length(
    chapters: List[Dict],
    min_len: int = 200,
    max_len: int = 1200
) -> List[Dict]:
"""
    章节长度均衡处理:超长拆分、超短合并
    :param chapters: 原始章节列表
    :param min_len: 最小长度阈值,低于该值尝试合并
    :param max_len: 最大长度阈值,超过该值尝试拆分
    :return: 均衡后的章节列表
    """

    balanced = []
    i = 0
    n = len(chapters)

while i < n:
        current = chapters[i]
        current_len = len(current["content"])

# 情况1:章节长度在合理区间,直接保留
if min_len <= current_len <= max_len:
            balanced.append(current)
            i += 1
continue

# 情况2:章节过短,尝试与下一个同主题章节合并
if current_len < min_len and i + 1 < n:
            next_ch = chapters[i + 1]
            merged_content = current["content"] + "\n\n" + next_ch["content"]
iflen(merged_content) <= max_len:
                merged_chapter = {
"chapter_id"f"{current['chapter_id']}_merged",
"chapter_title"f"{current['chapter_title']} / {next_ch['chapter_title']}",
"content": merged_content,
"content_hash": hashlib.sha256(merged_content.encode("utf-8")).hexdigest()
                }
                balanced.append(merged_chapter)
                i += 2
continue

# 情况3:章节过长,按段落拆分
if current_len > max_len:
            paragraphs = current["content"].split("\n\n")
            temp_content = ""
            sub_idx = 0

for para in paragraphs:
iflen(temp_content) + len(para) < max_len:
                    temp_content += para + "\n\n"
else:
                    sub_chapter = {
"chapter_id"f"{current['chapter_id']}_sub{sub_idx:02d}",
"chapter_title"f"{current['chapter_title']}{sub_idx + 1})",
"content": temp_content.strip(),
"content_hash": hashlib.sha256(temp_content.strip().encode("utf-8")).hexdigest()
                    }
                    balanced.append(sub_chapter)
                    temp_content = para + "\n\n"
                    sub_idx += 1

# 处理最后一段
if temp_content.strip():
                sub_chapter = {
"chapter_id"f"{current['chapter_id']}_sub{sub_idx:02d}",
"chapter_title"f"{current['chapter_title']}{sub_idx + 1})",
"content": temp_content.strip(),
"content_hash": hashlib.sha256(temp_content.strip().encode("utf-8")).hexdigest()
                }
                balanced.append(sub_chapter)
            i += 1
continue

# 边界情况:过短且无法合并,直接保留
        balanced.append(current)
        i += 1

return balanced

if __name__ == "__main__":
# 调用示例
    mock_chapters = [
        {"chapter_id""ch01""chapter_title""安全提示""content""禁止潮湿环境使用设备。""content_hash""xxx"},
        {"chapter_id""ch02""chapter_title""注意事项""content""开机前检查电源连接。""content_hash""xxx"},
        {"chapter_id""ch03""chapter_title""故障排查""content""故障1:报错A01\n\n故障2:报错A02\n\n" * 20"content_hash""xxx"},
    ]

    balanced = balance_chapter_length(mock_chapters)
print(f"原始章节数:{len(mock_chapters)},均衡后章节数:{len(balanced)}")
for ch in balanced:
print(f"{ch['chapter_id']} - {ch['chapter_title']},长度:{len(ch['content'])}")

八、落地实施建议

  1. 1. 落地优先级:优先落地双层章节分块、坐标降噪、表格结构化提取三项,可解决80%的分块质量问题。
  2. 2. 效果评测:搭建文档分块质量评测集,每次调整分块策略后,量化评估召回率、上下文完整度、Token消耗三个核心指标。
  3. 3. 增量联动:所有分块逻辑必须与章节哈希、版本机制联动,确保更新时可精准定位变更范围,避免全量重算。
  4. 4. 异常兜底:针对格式混乱的非标文档,降级为文档级全量更新模式,优先保证数据正确性,再逐步优化粒度。

欢迎同学添加小助手,获取代码或加入人工智能圈交流群