项目标题:Claim 抽取:先定 Chunk 再 Hybrid
做 Claim 抽取(也就是从文本里抽“可验证的主张/论断”)的时候,很多团队习惯一上来就调大模型,甚至把整份合同、整篇论文直接喂给模型去抽。我在这个方向上踩过一轮坑,现在的流程基本固定成了三步:先定 Chunk,再搭 Hybrid 检索,最后才轮到抽取模型本身。这个顺序一旦搞反,后面调 Prompt、换模型、调温度都像是在给一口漏水的锅补盖子。这篇文章把这条技术路线完整拆开讲,适合正在做知识抽取、事件抽取、文档智能处理,或者准备把大模型接进生产环境做信息提取的团队参考。
1. 为什么要“先定 Chunk 再 Hybrid”
1.1 任务理解:Claim 抽取到底在抽什么
先对齐一个概念。Claim 抽取和传统的“实体识别”“关系抽取”不是一回事,它更接近“论点级抽取”。比如从一篇临床试验报告里抽“该药物在X人群中降低了30%的复发率”,从一份合同里抽“甲方应在收到发票后10个工作日内付款”,从一条新闻里抽“某公司宣布以XX亿元收购某团队”。这些内容都带有主体、行为、条件、结论,而且通常散布在文档的不同位置。一个 Claim 的证据链往往跨越多句话甚至多个段落,这就决定了“把上下文完整地带给模型”比“抽模型本身有多强”更关键。
事件抽取是这个任务家族里最常见的一个子类。比如金融领域的“并购事件”、医疗领域的“不良反应事件”、舆情领域的“灾害事件”,它们的共同点是:事件触发词出现在前半段,论元分散在后续段落里,如果只给模型一小段碎片,论元一定抽不全。换句话说,Claim 抽取的上限,取决于你递给模型的上下文有多完整。
1.2 顺序背后的逻辑:检索上下文决定了抽取上限
为什么强调“先定 Chunk 再 Hybrid”?因为这两件事决定了抽取模型能“看到”什么。
假设你有一个 5000 词的文档库,抽取模型能接受的上下文窗口是 2000 词。你不可能把整个文档库塞进去,只能先切块(Chunking),再从这些块里找出和待抽取目标最相关的几个块(Hybrid Retrieval),最后把“查询条件 + 候选块列表”拼进 Prompt。这个链路里,Chunk 切得好不好,直接决定了候选块里“有没有料”;Hybrid 检索好不好,决定了“有料的块能不能被捞出来”。两个环节互相依赖,但 Chunk 是上游中的上游。Chunk 边界切断了一个 Claim 的证据链,再强的检索也捞不回来完整的上下文;检索漏召回了相关块,抽取模型就只能凭残缺信息硬猜。
我见过一个典型的反面案例:某团队做合同条款抽取,直接把整个合同按 512 字符硬切,结果“付款条件”和“违约责任”被切到两个 Chunk 里,模型每次都只看到一半,抽出来的 Claim 不是缺主语就是缺时间。后来他们花了两周调 Prompt、换模型,效果始终上不去。最后我把切分策略改成“按条款边界切 + 200 字符重叠”,同样一个模型,F1 直接涨了十几个点。这就是顺序的意义:先修好输入的地基,再谈模型的天花板。
1.3 常见错误路径:先调抽取模型再回头切片的代价
很多团队的实际路径是:先拿整篇文档跑抽取 → 发现长文档超限 → 改为“前 N 个字符”截断 → 用截断结果调 Prompt → 效果不行 → 怀疑模型 → 换更大的模型 → 成本翻倍效果还是不行。
这条路径最大的问题在于:把本该由上游解决的问题,全部堆积到了下游的模型身上。模型上下文窗口是有限的,即使强行塞得下,也会因为无关信息过多产生“注意力稀释”,导致关键约束被忽略。你让模型在 8000 字的上下文里找一个 “验收合格后15日内” 的付款条件,它往往会被中间的大段技术描述带跑。相比之下,先把 Chunk 边界规划好,再让检索只召回 2-3 个高相关片段,模型看到的每句话都紧扣主题,抽取准确率自然高。
从成本和响应速度来看,这个顺序同样划算。固定 Chunk + Hybrid 之后,每次抽取请求只需要携带少量候选片段,Token 成本稳定可控,接口延迟也从“处理整篇文档”的几十秒降到了“处理几个片段”的几秒。对于生产环境来说,稳定比“偶尔聪明”重要得多。
2. 第一步:Chunk 切分策略的设计与参数选择
2.1 按长度切分 vs 按语义边界切分
Chunk 切分不是简单地把文本剁成等长段落。按固定长度切(比如每 500 个 Token 一刀)实现简单,但问题很多:一个完整句子可能被拦腰切断,一个条件句的“如果”和“那么”分到了两个块里,模型拿到的块全是残句。对于检索来说,这种残句的语义向量质量也很差,因为 embedding 模型在编码“不完整的意思”时,生成的向量会偏向字面统计,而不是真正含义。
更可靠的做法是按语义边界切。具体来说,优先级从高到低是:文档结构边界 > 段落边界 > 句子边界 > 固定长度兜底。比如一份合同,先按“第X条”切;一份技术文档,先按 Markdown 标题和列表切;一篇论文,先按“引言/方法/结果/讨论”的章节切。段落和句子作为二级边界,最后才用 Token 长度做上限控制。这样切出来的每个 Chunk,内部语义相对完整,外部边界也比较清晰,后续无论是向量化还是关键词索引,质量都会高不少。
中文文本切分有个额外注意点:英文按空格和标点切句子很自然,中文必须按句号、问号、感叹号、分号做断句。我见过有人按字符长度硬切中文,结果一个完整论断被切成两半,模型抽取时频繁出现“张冠李戴”的主语。切分器里一定要内置中文标点的断句逻辑。
2.2 关键参数:chunk_size、overlap 与滑动窗口
切分器有三个参数需要认真斟酌:chunk_size(块大小)、chunk_overlap(块重叠)、滑动步长(stride)。
- chunk_size:单块的最大长度。建议按 Token 数设置,而不是按字符数。中文场景下 1 个字约等于 0.6-1 个 Token(取决于分词器),如果按字符切 500 字,实际 Token 数会超,很容易在拼接上下文时爆窗口。我常用的起点是 chunk_size=800 Token,大概对应 600-800 个汉字。
- chunk_overlap:相邻两个块相互重叠的长度。作用是避免切分边界刚好卡在关键信息中间。重叠太小没用,太大则会造成大量冗余检索结果。我的经验值是 100-200 Token,相当于块长度的 1/8 到 1/4。
- 滑动步长:等于 chunk_size - chunk_overlap。比如 chunk_size=800,overlap=150,那 stride=650,也就是说每 650 Token 切一个新块。这个公式可以帮助你在调整参数时保持逻辑一致。
为什么需要 overlap?我举个例子。我们抽取金融公告里的“担保事项”时,经常看到这样的句子:“……本次担保金额为 2.5 亿元,占公司最近一期经审计净资产的比例为 12.7%。该担保事项已经公司董事会审议通过。”如果硬切,第一块很可能只包含“担保金额为 2.5 亿元”,第二块从“占公司最近一期”开始,模型看到第一块时不知道这是谁担保的,看到第二块时又不知道对象是什么。加了 overlap 之后,两块都同时包含主语和金额,召回哪块都能抽对。
2.3 不同类型文本的切分配方
不同领域文本,切分策略不能一刀切。我整理了自己常用的几套配方:
| 文本类型 | 首选边界 | 推荐 chunk_size | 推荐 overlap | 备注 |
|---|---|---|---|---|
| 合同/法律文书 | 条款编号(第X条) | 600-800 Token | 100-150 | 同时保留条款标题作为元数据 |
| 新闻/资讯 | 段落+句子 | 500-700 Token | 50-100 | 新闻信息密度高,块别太大 |
| 论文/技术文档 | 章节标题 | 800-1200 Token | 150-200 | 方法部分通常连贯性强 |
| 对话/客服记录 | 话轮边界 | 300-500 Token | 50 | 按角色拼接,不要切断一问一答 |
| 公告/财报 | 章节+段落 | 800-1000 Token | 150 | 数字和比例关系容易被截断 |
这里有个容易被忽略的细节:切分时最好把块的元数据也带上,比如来源文件名、章节标题、页码。后面做 Hybrid 检索时,这些元数据既能用于过滤,也能在抽取结果里作为“证据出处”展示。我在实际项目里把这个信息拼进每个 Chunk 的前缀,效果比单纯切文本好很多,因为大模型在处理带来源标签的内容时,会更倾向于忠实原文而非自由发挥。
注意:chunk_size 不是越大越好。块越大,包含的无关信息越多,检索召回的“精确度”会下降;块越小,单块内上下文越不完整,召回后需要拼接的块也越多。800 Token 只是一个常用的平衡点,具体要以你自己的数据分布和测试结果为准。
3. 第二步:Hybrid 混合检索的设计与实现
3.1 向量检索能干什么、不能干什么
Chunk 切好之后,接下来是检索。为什么纯粹用向量检索不够?因为向量检索擅长的是语义相似,不是字面匹配。当你要找“付款时间”相关的内容,用户/上游系统输入的查询词可能是“结算周期”,向量检索能把这两个不同写法联系起来,这是它的优势。
但向量检索有两个明显的短板。第一,专有名词、编号、金额这些精确信息,向量相似度往往不敏感。比如你要抽取的是“合同编号 HT-2024-0815”对应的付款条件,查询词里带上这个编号去搜,向量检索很可能把它当成一个普通词组,召回排名反而靠后。第二,向量检索对查询词本身的噪声非常敏感,查询词里多一个无关词,结果就可能完全跑偏。尤其在中文场景里,同义词差异大、实体表述多样,纯靠 embedding 容易漏掉那些“字面完全一致但语义向量距离较远”的关键片段。
纯粹用 BM25 这类关键词检索也不行。它无法理解同义表达,比如“付款”和“结算”在 BM25 里是完全不同的词,文档里只出现“结算”时,你用“付款”去查就查不到。关键词检索还处理不了“以不同句式表达同一含义”的情况,这是要 Hybrid 混合检索的根本原因:两者互补,不是二选一。
3.2 BM25 关键词检索弥补了什么
BM25 是经典的关键词检索算法,核心思路是:一个词在文档里出现得越多,文档越相关;但这个词在所有文档里出现得越频繁,它的区分度就越低,权重会被抑制。它对精确匹配、业务编号、事件触发词非常有效。
我一般把查询词拆成两部分喂给 BM25:一是原样的用户查询词,二是从查询词里提取的实体/关键词列表。比如用户要查“XX公司2024年对外担保中的关联交易”,那么关键词列表可以是“XX公司”“对外担保”“关联交易”“2024年”。BM25 会把这些词和 Chunk 里的词做精确匹配,把包含这些词的块排到前面。
这里有个实践技巧:BM25 索引建立时,要对 Chunk 做同样的分词和去停用词处理。中文要用 jieba 或其他分词器先分词,否则 BM25 按单字匹配会产生大量噪声。英文则要注意词干化和大小写归一化。这些预处理决定了关键词检索的上限。还有一点:不要把整个长查询句丢给 BM25,查询句越长,包含的虚词越多,分数越被稀释。从长句里提取出 3-5 个核心关键词,检索效果会明显更好。
3.3 分数融合与候选片段重排
Hybrid 的难点在“怎么把两类检索结果合并成一个排序列表”。常见的两个方案:加权分数归一化和RFF(Reciprocal Rank Fusion)。
加权分数归一化:先把向量相似度分数和 BM25 分数各自归一化到 0-1,然后按权重加权:final_score = alpha * vector_score + (1 - alpha) * bm25_score。这个方案的好处是可以精细调权重,但对分数尺度敏感,需要保证两边的分数分布一致,否则强的那个会把弱的完全压掉。
RFF 方案:不管原始分数大小,只看每个 Chunk 在两个检索结果里的排名,计算公式是score = 1/(k + rank)(k 一般取 60)。比如一个块在向量检索里排第 1,在 BM25 里排第 5,那它的 RFF 分就是1/61 + 1/65 ≈ 0.0318。RFF 的优势是鲁棒,两个检索系统的分数分布再不一样也不影响融合,我把它作为默认方案。
融合之后,一般还会做一步重排(Rerank)。方法很直接:把候选的前 10-20 个 Chunk 拼成一小段上下文,用一个大模型或者交叉编码器(Cross-Encoder)按“和查询词的相关程度”重新打分。我用大模型做重排的经验是:单次重排输入控制在 2000 Token 以内,让模型输出“相关/不相关/部分相关”三分类,再用部分相关的内容辅助判断,效果比直接按分数截断要好。召回环节宁可多一些候选(Recall 优先),把精确筛选的工作留给重排和抽取模型。
实操心得:Alpha 权重别一上来就调。先从 alpha=0.5 开始跑基线,统计召回的 Top5 里有多少是向量独有的、多少是 BM25 独有的、多少是两者都召回的共同块。如果两类独有块里都有有效信息,说明两者都有贡献,保持 0.5 就够;如果向量独有块里全是噪声,才把 alpha 往 0.3 以下调;反之调高。纯靠跑一组实验看整体指标,很难定位问题在哪个环节。
4. 第三步:基于召回片段做 Claim 抽取
4.1 抽取任务建模:从整篇到片段级
Chunk 和检索都准备好之后,抽取任务的形态就变了:不再是把“整篇文档”塞给模型,而是把“查询条件 + 召回的 3-5 个 Chunk”作为输入,让模型输出结构化 Claim。
这种“片段级抽取”在实现上有几个好处。首先,每个 Chunk 内部语义相对完整,模型不需要在超长上下文里大海捞针。其次,当多个 Chunk 同时作为输入时,需要让模型分别对每个 Chunk 抽取,再做一次合并去重,而不是把所有 Chunk 混在一起一次性抽取。原因是混在一起时模型容易混淆信息归属,把 A 块里的时间配到 B 块的金额上。我在实际项目里让模型按 Chunk 逐个输出 JSON,再在代码层合并相同 Claim,错误率大幅下降。
举个例子,我们做“公司对外担保事件抽取”时,输出 schema 是固定结构,每个 Claim 包含:subject(担保方)、object(被担保方)、amount(金额)、ratio(占净资产比例)、time(公告时间)、event_type(担保类型)、evidence(证据原文)。模型对单个 Chunk 输出这样的 JSON 非常稳定,一旦把 5 个 Chunk 全塞进去,就会开始漏字段或者张冠李戴。
4.2 Prompt 设计与结构化输出
Prompt 设计上,我推荐“角色 + 任务 + schema + 负面约束”四段式。角色定义可以写“你是一个严谨的金融文档信息抽取器”;任务描述要说明“从给定的文本片段中抽取所有关于担保事件的 Claim”;schema 要用 JSON 格式明确列出每个字段的含义;负面约束很重要,比如“如果没有找到金额信息,输出 null,不要猜测”“如果文本片段不包含任何担保信息,输出空数组,不要生成虚假内容”。
结构化输出有两种实现方式:如果你的模型 API 支持 JSON Mode / Function Calling,就强制返回 JSON;如果不支持,就用“提取 JSON”类的 Prompt 让模型只输出 JSON 块,代码层再用解析器处理。实测下来,Function Calling 模式最稳,至少保证输出格式不崩。此外,温度参数建议调到 0,这类抽取任务不需要创造性,温度越高,字段漏填和数值篡改的概率越大。
关于证据字段,这个值得多说一句:每次抽取都必须把“证据原文”原样返回。这不仅是给下游审核用的,也是调试用的。当团队复核抽取结果时,如果发现某个 Claim 的 subject 和原文对不上,可以直接定位是 Chunk 切分的问题、召回的问题,还是模型理解的问题。没有证据字段的抽取结果,错了都不知道错在哪一步。
4.3 与 Kettle 等集成工具对接的分页抽取思路
很多实际的抽取任务并不只是跑一次模型,而是要落到数据流水线里,定时处理新增文档。这个时候绕不开 ETL 工具。我们用的方案是Kettle(Pentaho Data Integration)定时轮询接口,分页拉取待抽取文档,然后送入抽取服务。
具体流程可以拆成这样:
- 在 Kettle 里建一个“HTTP Client”步骤,调用后端的待处理文档列表接口。
- 接口支持分页参数,比如
page=0&size=500。Kettle 里用“循环”或者“变量递增”的方式每次翻页,直到返回的数据条数小于 page size 为止。 - 拉回来的每条记录,取其 id、标题、正文内容,写入中间库的
pending_docs表。 - 启动一个抽取调度任务,批量从
pending_docs里取未处理的文档,按第 2 节和第 3 节的方案做 Chunk 切分和 Hybrid 检索,再调用大模型抽取。 - 结果写回
claim_results表,同时把pending_docs.status更新为 processed。
这里容易被忽略的是分页状态记录。不要每次从头开始翻页,要记录上次处理到的页码和主键位置,否则文档量大了之后,全量翻页的耗时和接口压力都不可接受。Kettle 里可以用“表里的 max(processed_id)”作为下一页的起点,比用 page 翻页更稳。另外,抽取服务要设计成“幂等”的:同一个文档重复调用也能得到相同结果,这样重跑作业时不会产生重复 Claim。
顺带提一句,如果你的团队使用的是像 oneke 这类大模型知识抽取框架,我的建议是:框架帮你封装好了模型推理、schema 解析和部分评估逻辑,这是好事;但 Chunk 切分和 Hybrid 检索这两层,一定要自己控制。因为这两个环节强依赖你的业务文档结构,框架里的默认实现很难适配每一种文本。实践中比较顺的用法是:用框架的抽取接口,自己把候选 Chunk 拼好传进去,而不是把整个库交给框架去切。
5. 常见问题与排查调优实录
5.1 切分不当导致抽取结果漂移
现象:抽取出的 Claim 字段齐全、格式正确,但内容张冠李戴——比如把 B 公司的担保金额安到了 A 公司头上。这种问题 90% 出在 Chunk 切分上,而不是模型上。
排查思路是:先看证据字段。如果模型返回的证据原文本就不包含 A 公司,说明问题在上游;如果证据里包含 A 公司但和 Claim 里的金额来自不同位置,说明 Chunk 内混杂了多个公司的信息。这时需要把切分边界调整到“按每个公司的段落边界”切,或者缩小 chunk_size,避免多个主题被包进一个块。
还有一种常见漂移:模型的输出和原文数字不一致,多发生在金额、日期、比例上。原因通常是 Prompt 里没有强调“忠实原文”,或者温度没调到 0。我后面直接在负面约束里加了“所有数字必须与原文完全一致,禁止换算、四舍五入和推断”,这个问题基本消失。
5.2 Hybrid 检索权重怎么调都打不过单一检索
有时候你会发现,无论怎么调 alpha,Hybrid 的效果都不如纯向量检索,或者不如纯 BM25。这种情况通常是两类检索的候选集合本身就有问题,而不是权重问题。
先排查向量侧:embedding 模型和文档领域是否匹配。我们用通用中文 embedding 模型在金融公告上效果一般,换成在财经语料微调过的模型后,向量召回准确率明显提升。再排查 BM25 侧:分词是否正确。金融文本里“担保”这种词没问题,但“对价”“交割”这类专业词如果被切成单字,BM25 的匹配就废了。要做的是在分词器的自定义词典里加入领域专有词,BM25 的效果立刻不一样。
如果两边的单侧检索质量都没问题,但融合后仍然没有提升,可以考虑先用 RFF 替代加权分数归一化。我遇到过一次案例:向量分数和 BM25 分数的量纲差距太大,alpha=0.5 时加权结果几乎完全由向量主导,切到 RFF 后稳定性和效果都上去了。这类问题在盲调参数的时候很难发现,换成排名融合一看就清楚了。
还有一个不起眼但常见的原因:查询词和 Chunk 属于不同层级。比如查询词是“2024年担保情况汇总”,但 Chunk 里根本没有“汇总”这种词,只有具体条目。这时应该把查询扩展成多个子查询(如“2024年担保”“具体担保明细”“担保金额”),把多个子查询的结果合并去重,再交给重排。一个查询词打天下,召回集合很容易偏窄。
5.3 生产环境里两个容易踩的坑
坑一:把整篇文档重复塞进每个请求。我见过一个生产系统,每个文档切了 30 个 Chunk,按理应该只调 30 次抽取接口,但开发人员图省事,把所有 Chunk 全拼进一个 Prompt,一次性抽取。结果是 Token 消耗暴涨,响应时间从 3 秒变成 30 秒,抽取准确率反而下降。原因很明显:模型注意力有限,无关 Chunk 越多,关键信息越容易被淹没。
坑二:没有缓存 Chunk 和向量结果。同一个文档在首次处理之后,切分结果和 embedding 向量应该持久化存储(比如 ES + 向量索引),下一次查询直接检索即可。如果每次查询都重新切分、重新向量化,系统在文档量增长后会越来越慢。我们在生产环境里把 Chunk 文本、向量、元数据都缓存起来,检索响应从秒级落到了毫秒级。
排查问题的时候我建议建立一套端到端样例集:挑 30-50 条具有代表性的文档,人工标注出每个 Claim 对应的证据片段位置。每次改完 Chunk 参数、检索权重或 Prompt,都拿这套样例跑一遍,定位变化来自哪个环节。没有这套基准,任何调优都是凭感觉,做出来的系统很难稳定上线。
最后再分享一条个人经验:这套“先定 Chunk 再 Hybrid”的思路,不只是给大模型抽取用的。我后来做文本分类、摘要生成、甚至问答系统,都沿用了同样的原则——先保证输入片段边界合理,再考虑检索和生成。数据喂给模型之前多花一小时切分,后面省下来的调参时间可能是几十倍。