检索大赛的实验做到第三个赛段,我们组的策略已经基本稳定:前两轮分别解决了语料清洗和召回链路的问题,这轮的核心就放在“检索之后”的生成质量上。选型阶段我们聊了好几个候选底座,最后把文心一言4.5版本作为实验3的主测对象。这篇文章不是官方评测报告,只是我们参赛过程中的实测记录,包括评测集怎么搭、超参怎么定、踩了哪些坑、哪些问题调完之后依然无解,如果你也在做RAG类的评测或大模型应用选型,可以直接把这里的思路拿去改着用。
1. 实验3在整个赛题里的定位:为什么拿文心4.5做RAG底座
1.1 从“能召回”到“能回答”的赛段递进
大赛前两个实验的产出比较明确:实验1给了一堆混乱的企业知识库原始文档,要求做格式归一、段落拆分、噪声清洗;实验2的目标是把这堆清理过的语料做成可检索的索引,我们当时用的是BM25加向量混合检索,召回的Top10结果已经能稳定覆盖正确答案所在片段。
但到了实验3,题目突然变味了。赛题给出的要求是:给定知识库语料和一组问答题,参赛队伍要构建“检索增强问答系统”,模型不仅要给出正确答案,还要求输出引用信息的来源片段编号,并且在多轮对话场景里保持上下文一致。这就不是单纯比召回率了,而是比模型在拿到检索片段之后能不能把这些片段用对、用准、用对地方。换句话说,前两轮决定“系统能不能找到答案”,实验3决定“找到之后能不能让用户信服”。
1.2 候选模型选型的几个硬指标
组里讨论评测对象时,列了三个硬指标:中文语义理解能力、长文档指令遵循能力、引用格式的可控性。开源底座我们先跑了两个,效果能用但总觉得差一口气。文心4.5版本是后来补进来的候选,理由很实际:第一,它的API接入成本比想象中低,不需要自己部署推理节点;第二,官方文档里强调过它在中长文本的指令遵循和引用标注上有针对性优化,这正是实验3的得分点;第三,我们组里没有专职的后端工程师,能用API解决的事情绝不自建服务。
这里要给后来参赛或做选型的朋友提个醒:评测大模型的RAG能力,不要在零基础上直接对比“谁聪明”。一定要先把检索侧固定住,也就是不管换哪个底座,喂给模型的检索片段、排序方式、上下文格式完全一致,这样得出的差异才是模型本身的差异,否则变量太多,最后说不清楚到底是谁的问题。
2. 评测方案与评估集构造:比的是文档级问答的“资料可溯源率”
2.1 一千道题的三种难度层次
实验3的评测集我们没有用外部公开数据集,而是按赛题要求基于给定的知识库语料自建。说句实话,构造评测集花的时间比跑实验本身还多,因为题出得烂,后面所有数字都没有意义。
我们的做法是把1000道题按难度分成三层:600道单跳问题,这种题只需要在单个文档片段里找到直接答案,比如“报销差旅费需要提交哪些材料”;300道多跳问题,答案分散在多个文档里,需要模型先定位关联片段再推理归纳,比如“设备采购金额超过哪个阈值时需要走招投标流程,审批节点在哪个部门”;还有100道否定、比较和边界类问题,比如“以下哪种情况不属于急件申报范围”,这种题专门用来打击模型“见啥答啥”的本能。
构造完之后,我们让组里两个人分别抽了100道题做答案预标注,算出来的一致性在可接受范围里,才敢把这些题送去跑模型。如果有条件,这里建议至少做10%的题目双标,别看它慢,后面分析模型错误时会省非常多力气。
2.2 四个评分维度和一个自写的评测脚本
实验3的评分我们设了四个维度,官方也会给一套标准分,但我们坚持在内部保留自己的指标,用途是定位问题而不是比赛排名:
第一,答案正确率。直接对大模型的answer和人工标注的标准答案做语义相似度打分,我们用的阈值方案是BERTScore加关键词命中结合的判定,分数超过0.65算正确。
第二,引文准确率。这是实验3最核心的指标,也是很多队伍翻车的地方。判断方法是:模型输出的source_id是否真的支撑它说的那句话。我们会在后处理阶段把模型引用的片段单独抽出来,和标准答案的支撑证据做包含关系匹配,匹配上了才算引文正确。
第三,格式合规率。赛题要求答案必须包含answer字段和source_ids字段,没按格式输出的一律判零,这个指标看着简单,实际很容易被模型花式挑战。
第四,平均响应延迟。我们用首token延迟加总耗时的组合测量方式,主要目的是控制比赛现场演示的节奏,并不指望大模型能有多快。
2.3 基线跑测的固定配置
第一轮基线我们不追求好看的数字,只想把系统的下限探出来。检索侧统一用实验2留下的双路检索结果,取Top10片段,每段限制在300个中文字符左右。模型侧除了系统提示词里说明“你是一个企业知识库问答助手,请结合资料回答”,其他参数全部走API默认值。下面这段代码是我们当时的评测入口,跑一轮1000题大概需要40分钟:
import asyncio from openai import AsyncOpenAI client = AsyncOpenAI( api_key="your_key_here", base_url="your_endpoint_here" ) async def evaluate_one(q_item, context_blocks): messages = [ {"role": "system", "content": "你是一个企业知识库问答助手,请结合给定资料回答问题。"}, {"role": "user", "content": f"资料:\n{context_blocks}\n\n问题:{q_item['question']}\n\n请输出答案并给出资料编号。"} ] resp = await client.chat.completions.create( model="ernie-4.5", messages=messages, temperature=1.0 ) return resp.choices[0].message.content注意这段代码里我们故意把temperature拉满到1.0,因为基线评测要看模型在最不受控状态下的表现,后面调优才有对比意义。这里先剧透一下:这个决定让我们后面处理幻觉问题时多花了两天时间。
3. 基线首跑:好看的回答,经不起追问
3.1 首轮跑测数据一览
1000道题跑完,汇总结果如下表:
| 评测维度 | 数值 |
|---|---|
| 答案正确率 | 78.4% |
| 引文准确率 | 52.1% |
| 格式合规率 | 86.0% |
| 平均响应耗时 | 2.3秒 |
答案正确率接近八成,看起来还像回事,但引文准确率只有52.1%,基本可以说一半以上的回答在“乱标来源”。格式合规率也让人意外,明明系统提示词里已经说了要按格式输出,还是有14%的回答自由发挥。这说明单靠一句轻飘飘的提示词,控制不住文心4.5在复杂任务上的行为惯性,它默认你要的是“完整回答”,而不是“结构化输出”。
3.2 四个高频翻车类型拆解
第一类问题出在事实性回答的“过度顺畅”上。举例来说,有道题问“维修申请单归档由哪个岗位执行”,标准答案在检索片段里写的是“维修管理员负责归档”,模型输出里除了这句正确的话,还额外编了一句“归档前需经技术主管复核”,这句话看起来非常合理,但知识库里根本没有这个流程。这种幻觉不是凭空乱说,而是把通用行业常识和给定语料混在了一起。
第二类是引文错位,也是占比最高的问题。模型输出一段话里标了三个引文编号,但人工去核对时发现只有最后一个编号对应的片段确实包含该信息,前两个编号对应的检索块内容和这句话完全不沾边。这里能明显看出,文心4.5在没有强约束时,把引文编号当成了一种“格式装饰”,而不是“证据索引来验证”。
第三类是否定问题的处理失灵。问“哪一项不属于设备巡检的必查项目”,四个选项里只有一个是干扰项,模型应该答出这个干扰项。但基线跑测里,模型像被固化了一样把标准作业手册里列出的三个必查项目全答了出来,还配了引用编号,等于把“不属于”硬理解成了“属于”。
第四类是长上下文末尾信息的丢失感。当检索片段超过8块、总字数超过2400字时,模型回答更倾向于引用前文片段的信息,对后置片段里的细节容易忽略。我们后来分析这是注意力分布的问题,不能说模型没看到,但它在生成时更“急着”选前面的内容来支撑。
3.3 按错误类型归类之后真相更扎心
把所有错误案例归类统计后,得分损失比答案正确率更能说明问题:
| 错误类型 | 出现频次 | 对引文准确率的影响 |
|---|---|---|
| 引文编号与内容不对应 | 188例 | 高 |
| 补充了知识库不存在的流程/角色 | 76例 | 高 |
| 否定/比较类问题判反方向 | 41例 | 中 |
| 格式输出不符合预期 | 140例 | 低但致命 |
| 长上下文遗漏关键尾部信息 | 33例 | 中 |
当时我们组有个感受:模型的单点能力确实强,但它默认的“回答习惯”和检索增强场景要求的“严格引用习惯”存在明显张力。后面所有调优动作,本质上都是在跟这种默认习惯做对抗。
4. 把问题按根因拆开之后,我们的调优顺序
4.1 为什么先动采样参数而不是先动提示词
第一轮大家最容易犯的错是一上来就写一大段提示词模板。我们组的经验是,先动采样参数,让模型处于一个“更容易听话”的状态,再上提示词约束,这样能减少变量干扰。
文心4.5的temperature默认策略偏向语义丰富性,生成的句子确实漂亮,但对引文编号这类精确元信息的控制明显不够。我们做了一组小实验,抽了50道引文错位最严重的题,把temperature从1.0逐步降到0.1,每档跑一遍,发现温度在0.3以下时引文错位率只是略有下降,但配合其他手段后收益显著。最终我们把temperature定在0.25,top_p设为0.7,这个组合在“回答流畅性”和“格式遵循度”之间平衡得最好。
4.2 检索上下文的格式改造:让编号本身携带证据属性
基线版本里,我们传给模型的资料长这样:
资料片段1:…… 资料片段2:……这种格式下模型很难把编号和内容绑定起来,因为编号是“无意义序列”,模型为了语言通顺会随意复用编号。我们改造后的格式是给每个检索块加一个中括号索引和来源段落号,并在系统提示词里明确要求“只能引用上述编号,禁止扩写信息来源”:
def build_context(blocks): formatted = [] for idx, block in enumerate(blocks, 1): formatted.append( f"[{idx}](来源段落{S{RecId}}) {block['text']}" ) return "\n".join(formatted)这样模型在生成source_ids时,至少能感知到编号背后对应着一段可回查的原文,而不是一个自由的占位符。这一步做完,格式合规率第一次跳到了95%以上。
4.3 用结构化输出槽位锁死生成边界
提示词说得再清楚,模型偶尔还是会自由发挥。我们上加了一层保险:让模型在JSON模式下输出固定结构,answer字段给最终答案,reason字段给推理摘要,source_ids字段给引用编号数组。
schema = { "type": "json_schema", "json_schema": { "type": "object", "properties": { "answer": {"type": "string"}, "reason": {"type": "string"}, "source_ids": {"type": "array", "items": {"type": "integer"}} }, "required": ["answer", "reason", "source_ids"] } }这一步做完,格式合规率基本稳定在97%以上,更重要的是我们后续做错误分析时不需要再做一层解析适配,整个分析链路顺了很多。但我得提醒一句:结构化输出不等于答案内容变正确了,它只是把“跑题”从格式层面挪到了内容层面,不要指望一个schema解决所有问题。
4.4 双路检索加重排序,把所有调优红利锁进上下文里
我们前两轮实验留下的双路检索,在生成侧调优到一半时就感觉到了瓶颈。有些题的正确答案确实在Top10里,但排在第九、第十的位置,模型基本不怎么参考。后来我们引入了重排序策略,把Top10压缩成Top5再喂给模型,并且保证压缩后覆盖两种检索通道的合并结果。
这一步本身不依赖文心4.5,但帮了它大忙。模型拿到更紧凑、更靠前的上下文后,尾部长文本被忽略的问题明显缓解。去重后的检索块平均长度控制在1800字以内,正好落在模型注意力表现稳定的范围内。
5. 第二轮结果的数字变化,和几个还没解决的点
5.1 调优后的指标对比
所有调整叠加上线后,我们又完整跑了一遍1000道题。对比如下:
| 评测维度 | 基线结果 | 调优后 | 变化 |
|---|---|---|---|
| 答案正确率 | 78.4% | 89.6% | +11.2% |
| 引文准确率 | 52.1% | 73.4% | +21.3% |
| 格式合规率 | 86.0% | 97.8% | +11.8% |
| 平均响应耗时 | 2.3秒 | 2.6秒 | +0.3秒 |
整体提升非常明显,特别是引文准确率从不及格拉到了七成以上,说明前面那套“降温度、强格式、缩上下文”的方案方向是对的。但说实话,这个73.4%在我们看来依然算不上及格,因为这意味着每四题里就有一题引用了错误的来源片段,在真实知识库场景里已经足够让最终用户对系统失去信任了。
5.2 消融实验:哪个改动贡献最大
为了知道哪些操作值得保留,我们做了一组简单的消融测试,在调优后配置上逐项回退:
| 回退项 | 引文准确率变化 | 答案正确率变化 |
|---|---|---|
| 温度改回1.0 | -8.2% | -3.4% |
| 去掉编号携带来源段落号 | -6.7% | -1.2% |
| 去掉JSON schema约束 | -4.5% | -0.8% |
| 去掉重排序、回退到Top10 | -5.1% | -3.9% |
温度控制和上下文格式改造贡献最大,这两者本质上都在解决同一个问题:让模型在生成时把“引用”当成一种需要严格完成的推理动作,而不是语言润色动作。JSON schema的贡献稍弱,但它带来的解析稳定性和可调试性是数据看不出来的。
5.3 调优之后依然搞不定的硬骨头
数字变好看了,不等于问题消失了。我们把剩余错误案例翻了一遍,发现有三类问题在现在的配置下仍然无解。
第一类是多跳问题里的“逻辑链断裂”。模型能定位到文档A和文档B,但它在生成时会把两段信息拼成一个听起来合理但实际错误的因果关系。比如“预算审批需要财务负责人签字”和“超预算项目需要重新立项”两件事,模型拼成了“超预算项目需先经财务负责人签字再重新立项”,知识库里并没有这个先后关系。这种错误引文编号都是对的,但推论逻辑是错的,用现有评估方法很难自动识别。
第二类是文风倾向导致的“过度回答”。即使温度降到0.25,模型在回答编号类、枚举类问题时,还是会不由自主地补充“此外”“同时”等连接词引导的额外信息。这些额外信息有时是正确的,但这已经超出了问题本身的边界,在严格评分规则下会扣答案准确分。
第三类是长上下文中间位置的信息遗忘。尾部丢失的问题靠重排序缓解了,但中间段的内容如果和头部内容语义相近,模型容易混淆两个不同片段里的细节信息。这种情况在多跳问题里尤其致命,因为多跳本身就需要跨多个位置的精确对照。
6. 横向对比和适合落地的配置建议
6.1 与两个开源中文底座放在同一套流程里测
为了确认文心4.5的调优收益是模型能力带来的,还是我们这套流程对任何模型都有效,我们把调优后的完整链路原样移植到两个开源中文底座上跑了一遍。结果如下:
| 模型 | 答案正确率 | 引文准确率 | 格式合规率 |
|---|---|---|---|
| 文心4.5 | 89.6% | 73.4% | 97.8% |
| 开源底座A(12B级) | 81.2% | 61.5% | 88.9% |
| 开源底座B(7B级) | 76.8% | 54.3% | 82.4% |
结论很清楚:相同的检索、相同的提示词结构、相同的温度参数下,文心4.5在指令遵循和引文准确性上的优势仍然显著,尤其格式合规遥遥领先。开源底座A在简单单跳问题上的答案正确率差距没想象中大,但一旦涉及多跳和长文本引用,差距会迅速拉开。
6.2 落地工程里最容易被忽略的引用字段设计
如果你是准备把这类系统接到实际业务里,比模型选型更重要的是引用字段的规范化设计。我的建议是,source_ids不要只存整数编号,前端展示时需要回溯到原文,所以在API响应里给一个完整的引用对象数组会更稳妥:
{ "answer": "维修申请单由维修管理员负责归档。", "reason": "依据知识库文档KB-2024-0712第3.2节。", "citations": [ { "source_id": 5, "doc_id": "KB-2024-0712", "section": "3.2 归档管理", "text": "维修管理员负责维修申请单的归档工作……", "score": 0.87 } ] }这样的字段设计让你在后端做权限控制、来源高亮、反直觉数据排查时都游刃有余,而不是拿到一个裸的source_ids再翻一次原始数据。我们在实验3后半段重新设计了响应结构,整个分析流程省了至少三成时间。
6.3 我现在的默认配置和后续可以继续做的事
经过这几轮折腾,我们组当前用于这类知识库问答场景的默认配置基本定型:文心4.5,温度0.25,top_p 0.7,上下文用带来源段落号的编号格式,输出走JSON schema加上面那个引用对象数组,检索侧保持BM25加向量的双路召回并按相关性重排到Top5。这个配置不能保证面向所有领域都最优,但如果你不知道从哪里起步,直接抄这套作业不会翻车。
后面我们打算做两件事:一是把评测集里的错误案例反哺回检索侧,看看是不是有些问题通过补充检索片段就能让模型改口,而不是一味压生成侧;二是尝试给模型提供“不确定就直说”的兜底路径,把那些硬骨头问题全部转成“无法从现有资料确认”的答案,宁可牺牲一部分正确率,也要把引文准确率拉到90%以上。至少在这个比赛场景里,让模型“克制”比让模型“聪明”更值钱。