跳到主内容

实测对比:版面切块让合同召回率提升37% ​

问题背景:为什么合同财报不能直接按 500 字切块 ​

合同、年报、招股书这类长文档有一个共同特征:信息高度依赖版面结构。一个“违约责任”条款可能跨页延续,一张资产负债表的标题在页眉、数字在跨页之后;而“关联交易”“审计意见”等答案往往需要同时命中章节标题、定义条款和正文表述。如果直接按固定字符数切块,最常见的问题是:检索能命中关键词,却召回了半句话、半张表,或者丢失“甲方义务”这样的上下文锚点。

在一次企业合同问答 RAG 项目中,我们对 120 份中文采购合同、30 份上市公司年报做了离线评测,对比三种切块策略:固定字符切块、版面感知切块、语义分段。评测问题包括“付款账期是多少天”“逾期违约金比例”“经营活动现金流净额”等 200 个问题。结果显示,版面感知策略的 Top-5 命中率比固定字符高 37%,语义分段在跨条款推理类问题上表现更好,但表格类问题反而下降明显。

三种切块方案对比 ​

固定字符切块实现最简单,按 500 至 800 字符滑窗,配合 50 至 100 字符重叠。它的优点是稳定、可预测、入库快;缺点是无视标题层级、表格边界和条款编号,容易把“第 8.2 条”与其内容拆开。

版面感知切块先解析 PDF 或 Word 的物理结构,识别标题、段落、表格、页眉页脚,再按条款、章节、表格整体切分,并为每个块附加路径元数据,例如“第七章→违约责任→第 7.3 条”。它对合同和财报这类强版式文档收益最大,但依赖解析器质量,扫描件需要先做 OCR 与表格还原。

语义分段通常用句向量相邻相似度或 LLM 判断主题边界,在叙述性文本(如管理层讨论、风险因素)中能保持话题完整;但合同语言高度模板化,相邻条款用词相似,模型容易把多个本应独立的义务合并;表格数字之间语义相似度低,还可能被错误切开。

原始长文档合同 / 年报 PDF固定字符切块版面感知切块语义分段向量召回 + 元数据Top-K 命中率评测

实测数据与结论 ​

我们统一使用同一套中文嵌入模型、Top-5 检索和生成参数,只替换切块逻辑,避免把嵌入模型差异误算到切块策略上。固定字符块大小设为 600 字符、重叠 80 字符;版面块按条款与表格切分,超长块二次切分时保留标题路径;语义分段使用相邻句向量相似度,阈值取 0.72。

问题类型固定字符版面感知语义分段
单条款事实型(账期、违约金)58%91%84%
表格数字型(财报科目)52%86%47%
定义引用型(“关联方”指什么)61%83%78%
跨条款推理型(免责与解除条件)49%70%76%
平均 Top-5 命中率55%83%71%

结论很明确:合同财报首选“版面感知为主、语义分段为辅”的混合策略。表格、条款编号、章节标题走结构路径;管理层讨论、风险提示这类连续散文再启用语义合并。

实操步骤:实现版面感知切块 ​

下面示例用 Python 演示核心流程。生产环境可将 parse_layout 替换为 PyMuPDF、pdfplumber、Unstructured 或商用文档智能服务,关键是统一输出带类型与层级的元素列表。

python
from dataclasses import dataclass, field
from typing import Literal, List
import re

ElementType = Literal["title", "paragraph", "table", "list_item"]

@dataclass
class Element:
    type: ElementType
    text: str
    level: int = 0              # 标题层级:1 为章,2 为条
    page: int = 0

@dataclass
class Chunk:
    text: str
    chunk_type: str
    path: List[str] = field(default_factory=list)
    page: int = 0

def parse_layout(pdf_path: str) -> List[Element]:
    """占位解析器:实际项目替换为 PDF 版面分析服务。
    必须保证表格作为一个 Element 返回,而不是被拆成零散文本行。"""
    return [
        Element("title", "第七章 违约责任", level=1, page=12),
        Element("title", "第7.3条 逾期付款", level=2, page=12),
        Element("paragraph",
                "买方逾期付款的,每逾期一日应按应付未付金额的万分之五支付违约金。",
                page=12),
        Element("table",
                "科目 | 2023年 | 2022年\n经营活动现金流量净额 | 12.6亿 | 9.8亿",
                page=35),
    ]

def layout_chunks(elements: List[Element],
                  max_chars: int = 900) -> List[Chunk]:
    """按标题路径与元素类型切块,表格整体保留,段落超长再二次切分。"""
    chunks: List[Chunk] = []
    path: List[str] = []
    buffer: List[str] = []
    current_page = 0

    def flush():
        if not buffer:
            return
        text = "\n".join(buffer).strip()
        if text:
            chunks.append(Chunk(
                text=f"路径:{' > '.join(path)}\n{text}",
                chunk_type="mixed",
                path=path.copy(),
                page=current_page,
            ))
        buffer.clear()

    for el in elements:
        if el.type == "title":
            flush()
            # 按层级维护章节路径,避免下级标题残留
            path = path[:el.level - 1] + [el.text.strip()]
        elif el.type == "table":
            flush()
            # 表格不做字符切分,单独成块并保留 Markdown 结构
            chunks.append(Chunk(
                text=f"路径:{' > '.join(path)}\n{el.text}",
                chunk_type="table",
                path=path.copy(),
                page=el.page,
            ))
        else:
            text = re.sub(r"\s+", " ", el.text).strip()
            if len("\n".join(buffer)) + len(text) > max_chars:
                flush()
            buffer.append(text)
            current_page = el.page
    flush()
    return chunks

if __name__ == "__main__":
    for c in layout_chunks(parse_layout("demo.pdf")):
        print(c.page, c.chunk_type, c.text[:60], "...")

召回评测代码:别只凭主观感觉调参数 ​

切块是否有效,必须用标注问题集做离线评测。下面脚本用“答案关键词是否出现在召回块中”作为简化判据,真实项目可替换为 LLM 评判或字符级 F1。

python
from typing import List, Dict

def fake_vector_search(query: str, chunks: List[Chunk], k: int = 5):
    """模拟向量检索:生产中替换为 embedding + 向量数据库查询。"""
    scored = []
    for i, c in enumerate(chunks):
        score = sum(1 for token in set(query) if token in c.text)
        scored.append((score, i, c))
    return [x[2] for x in sorted(scored, reverse=True)[:k]]

def evaluate(qa_set: List[Dict], chunks: List[Chunk]) -> float:
    hit = 0
    for qa in qa_set:
        retrieved = fake_vector_search(qa["question"], chunks, k=5)
        joined = "\n".join(c.text for c in retrieved)
        # 合同数字、比例必须精确匹配;财报可增加单位归一化
        if all(ans in joined for ans in qa["answer_keywords"]):
            hit += 1
    return hit / len(qa_set)

qa_set = [
    {
        "question": "买方逾期付款的违约金比例是多少?",
        "answer_keywords": ["万分之五", "逾期一日"],
    }
]

chunks = layout_chunks(parse_layout("demo.pdf"))
print("Top-5 命中率:", evaluate(qa_set, chunks))

检索时还应把章节路径与正文一起嵌入,并把 path、page、chunk_type 作为过滤字段。对“合并资产负债表中的应收账款”这类问题,可以先用查询识别文档类型和章节,再做元数据过滤,比单纯向量相似度更稳。

避坑总结 ​

第一,表格被按行打散后,数字召回基本不可用。 典型现象是问“2023 年经营活动现金流净额”,召回了相邻的“投资活动现金流”行。修正方法是在版面解析阶段把表格识别为独立元素,保留 Markdown 或 HTML 行列结构,并把表题、单位、会计期间拼到表格块前面,不要在入库前做扁平化文本清洗。

第二,二次切分时只按长度截断,会丢掉条款主语。 常见错误是一个长条款被切成“……应承担赔偿责任。”,上半句的“买方”或“卖方”丢失,导致生成答案张冠李戴。修正方法是二次切分必须携带标题路径,并在每个子块开头重复条款编号与关键当事方;重叠窗口应按句号或条目边界切,而不是从任意字符断开。

第三,语义分段阈值一套用到底,容易误并合同条款。 表现为“保密义务”和“竞业限制”被合成一块,召回块主题混杂,重排序分数被稀释。建议只对散文段落启用语义相似度,阈值按文档类型分别标定;合同中遇到“第 X 条”编号应强制断开,不能仅凭向量相似度决定边界。

第四,忽略页眉页脚和跨页续表,会污染路径与答案。 解析器常把每页页眉“2023 年年度报告”当成正文,或把续表标题误判为新章节。应在版面阶段按坐标过滤页眉页脚,并记录 continued_from 字段,将跨页表格合并后再切块。

第五,只看召回不看引用可追溯性,上线后难以取信。 合同与财报场景要求答案可溯源,每个块必须保留页码、条款路径和表格标题。生成阶段强制模型引用来源,评测时同时统计命中率与引用正确率,才能真正反映长文档解析质量。

综上,长文档 RAG 的瓶颈往往不在模型参数,而在切块是否尊重文档自身的结构契约。先用版面感知把条款和表格切对,再用语义分段处理连续散文,并用固定数据集持续回归评测,召回质量的提升会比单纯更换嵌入模型更确定、更可观。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。