外观
实测对比:版面切块让合同召回率提升37%
问题背景:为什么合同财报不能直接按 500 字切块
合同、年报、招股书这类长文档有一个共同特征:信息高度依赖版面结构。一个“违约责任”条款可能跨页延续,一张资产负债表的标题在页眉、数字在跨页之后;而“关联交易”“审计意见”等答案往往需要同时命中章节标题、定义条款和正文表述。如果直接按固定字符数切块,最常见的问题是:检索能命中关键词,却召回了半句话、半张表,或者丢失“甲方义务”这样的上下文锚点。
在一次企业合同问答 RAG 项目中,我们对 120 份中文采购合同、30 份上市公司年报做了离线评测,对比三种切块策略:固定字符切块、版面感知切块、语义分段。评测问题包括“付款账期是多少天”“逾期违约金比例”“经营活动现金流净额”等 200 个问题。结果显示,版面感知策略的 Top-5 命中率比固定字符高 37%,语义分段在跨条款推理类问题上表现更好,但表格类问题反而下降明显。
三种切块方案对比
固定字符切块实现最简单,按 500 至 800 字符滑窗,配合 50 至 100 字符重叠。它的优点是稳定、可预测、入库快;缺点是无视标题层级、表格边界和条款编号,容易把“第 8.2 条”与其内容拆开。
版面感知切块先解析 PDF 或 Word 的物理结构,识别标题、段落、表格、页眉页脚,再按条款、章节、表格整体切分,并为每个块附加路径元数据,例如“第七章→违约责任→第 7.3 条”。它对合同和财报这类强版式文档收益最大,但依赖解析器质量,扫描件需要先做 OCR 与表格还原。
语义分段通常用句向量相邻相似度或 LLM 判断主题边界,在叙述性文本(如管理层讨论、风险因素)中能保持话题完整;但合同语言高度模板化,相邻条款用词相似,模型容易把多个本应独立的义务合并;表格数字之间语义相似度低,还可能被错误切开。
实测数据与结论
我们统一使用同一套中文嵌入模型、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 的瓶颈往往不在模型参数,而在切块是否尊重文档自身的结构契约。先用版面感知把条款和表格切对,再用语义分段处理连续散文,并用固定数据集持续回归评测,召回质量的提升会比单纯更换嵌入模型更确定、更可观。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。