Llamaindex结构化输出与RAG评估实战:从契约到量化
从“模型输出的纯文本没法直接入库”这个问题说起吧。我去年做过一个企业知识库问答项目最开始的做法很野让模型返回一段带标记的文本再用正则表达式去抠字段。结果客户那边说“系统偶尔会把员工的姓名解析成部门名”一查是模型在长文本输出里自作主张改了字段顺序正则匹配全错。后来我把整套逻辑迁到Llamaindex上发现它原生就把“结构化输出”和“评估”这两件事做成了体系用Pydantic声明输出契约用三类评估器量化RAG效果。这篇文章就把这两块拆开讲清楚适合已经跑通基础问答、但觉得效果不可控、想往生产环境推的人看。1. 结构化输出别再让模型自由发挥了1.1 直接要求“输出JSON”模型为什么还是翻车很多人在第一次做LLM应用时都会试过这种写法你是一个信息抽取助手请从文本中提取姓名、职位、入职年份并以JSON格式返回。看起来很简单实际跑起来问题一大堆。第一个问题是字段缺失。模型心情好的时候给你四个字段心情不好只给三个关键字段说没就没。第二个问题是字段名漂移。你要求用employee_name它给你返回name或者更离谱返回一个full_name。第三个问题是类型不稳定。你声明age是整数它给你返回字符串“28岁”前端拿到直接崩溃。第四个问题最隐蔽模型在JSON外面包裹解释性文字比如“好的这是你要的JSON”然后才是一段代码块解析器直接报错。有人会说我用正则修一下就好了。但正则本质上是在给模型的“随机行为”打补丁今天补丁能跑明天换一个文档、换一个prompt措辞模型又开始自由发挥。这种方案在演示Demo里能糊弄过去放到生产环境就是一颗定时炸弹。1.2 Llamaindex的结构化输出思路声明式契约Llamaindex的思路不是“尽量约束模型”而是“让模型按照一个可校验的契约输出”。它的核心思想是先定义好你想要的输出结构再把结构转换成模型能理解的形式最后自动完成解析和校验。这个思路和传统编程里的“接口定义”很像。写后端接口时你先定义request和response的DTO然后才写业务逻辑。LLM输出也应该这样先用Pydantic声明字段、类型、约束再让Llamaindex去处理底层的prompt构造、函数调用、结果解析。这样做有三个直接好处字段缺失或类型错误会在本地抛出ValidationError而不是等到下游程序运行时才崩溃。可以在Pydantic字段上写描述Field(description...)这些描述会被注入到prompt里引导模型理解每个字段的含义。拿到的是真正的结构化对象可以直接序列化、入库、喂给下游函数。在Llamaindex 0.10.x版本里有三条路可以走llm.structured_predict、PydanticProgram、以及Query Engine层直接传output_cls。下面逐一展开。2. 锁定输出结构Pydantic模型与解析器实战2.1 定义你的数据契约不管走哪条路第一步都是定义一个Pydantic模型。我用一个音乐专辑抽取的场景做示例假设我们要从一段乐评里提取专辑信息。from pydantic import BaseModel, Field from typing import List, Optional class Track(BaseModel): 单曲信息 name: str Field(description单曲名称) duration_seconds: Optional[int] Field( defaultNone, description单曲时长单位秒未知则为None ) class AlbumInfo(BaseModel): 专辑信息 title: str Field(description专辑标题) artist: str Field(description歌手或乐队名称) release_year: int Field(description发行年份) genres: List[str] Field(description音乐风格列表) tracks: List[Track] Field(description专辑包含的单曲列表)这里有两个容易被忽略的细节。第一Field(description...)不是摆设。Llamaindex在构造prompt时会把模型字段的description拼进去模型实际上是通过这些描述来理解“该往这个字段里填什么”。字段名本身往往不够表达语义比如release_year模型可能不确定是首发年份还是再版年份你在description里写清楚“发行年份”抽取准确率会明显提升。第二Optional[...]和default的用法很重要。真实文本里不一定包含所有信息比如单曲时长经常未知。如果你声明成必填字段解析时模型为了凑字段就会编造一个数值这比空着还要危险。标成Optional模型在信息不足时就更倾向于留空而不是瞎猜。2.2 用PydanticOutputParser串联解析流程定义好模型之后最底层的用法是PydanticOutputParser加StructuredOutput。这段代码建议理解因为它是后续所有高层封装的基础。from llama_index.core.output_parsers import PydanticOutputParser from llama_index.llms.openai import OpenAI # 构造解析器内部会根据 AlbumInfo 的 schema 生成输出格式说明 parser PydanticOutputParser(output_clsAlbumInfo) format_instructions parser.format_string() # 在 prompt 里加入 format_instructions # 假设已有 llm 实例 llm OpenAI(modelgpt-4o-mini) # 拿到模型原始输出后交给 parser 解析 response llm.complete( Extract fields from the following review:\n 《XX》是YY乐队2021年发行的第三张录音室专辑风格以前卫摇滚为主... ) raw response.text # parse 过程先用 JSON 解析再交给 Pydantic 校验最终返回 StructuredOutput parsed_result parser.parse(raw) print(parsed_result.raw_output) # 原始文本 print(parsed_result.parsed_output) # AlbumInfo 实例StructuredOutput里有两个关键属性raw_output和parsed_output。raw_output是模型返回的原始文本parsed_output是解析并校验后的Pydantic对象。这两个字段的区分很重要调试时先看raw_output能确认模型到底输出了什么如果parsed_output为None说明解析或校验环节出了问题。2.3 三种主流调用方式对比直接手动调用parser有点繁琐日常开发我更推荐用下面三种高层封装。它们的底层最终都会走“构造prompt 解析 校验”这个流程但适用场景不同。调用方式典型代码入口适用场景备注llm.structured_predictllm.structured_predict(output_clsAlbumInfo, prompt..., ...)想直接调用LLM做一次结构化抽取不经过索引/查询引擎最轻量适合单次抽取LLMTextCompletionProgram/OpenAIPydanticProgramprogram LLMTextCompletionProgram.from_defaults(output_cls..., llm...)需要复用同一个抽取流程传不同输入跑多次可封装成服务Query Engine传output_clsindex.as_query_engine(..., output_clsAlbumInfo)基于检索问答要求最终答案按结构化格式返回和RAG链路耦合度最高先看structured_predict的用法它适合在需要临时抽取时快速调用from llama_index.core.llms import LLM from llama_index.core.prompts import PromptTemplate prompt PromptTemplate( 从下面的乐评中提取专辑信息。\n 乐评内容{review_text}\n {format_instructions} ) album llm.structured_predict( output_clsAlbumInfo, promptprompt, review_text..., ) print(album.title, album.artist, album.release_year)再看LLMTextCompletionProgram它相当于把上面的逻辑包装成了一个可复用对象from llama_index.core.program import LLMTextCompletionProgram program LLMTextCompletionProgram.from_defaults( output_clsAlbumInfo, prompt_template_str( 从下面的乐评中提取专辑信息。\n 乐评内容{review_text}\n {format_instructions} ), llmllm, ) result: AlbumInfo program(review_text...)对于RAG场景最爽的其实是第三种直接在查询引擎上挂output_cls。这样检索、合成、结构化输出一条链路全部自动化。query_engine index.as_query_engine( output_clsAlbumInfo, response_modetree_summarize, ) resp query_engine.query(这套专辑的发行年份和风格是什么) # resp.response 是序列化后的 JSON 字符串 # 但更推荐直接走自定义查询引擎把 parsed 结果暴露出来说实话第三种的细节在不同版本下略有差异有些版本需要配合as_structured_query_engine这类接口。我的建议是如果只是为了快速验证用structured_predict如果是做正式RAG应用优先把output_cls挂到查询引擎上这样最省心。3. 结构化预测的容错与边界3.1 为什么优先走函数调用模式如果你用OpenAI系模型会注意到Llamaindex在structured_predict时优先走function calling / tool calling机制。它的原理是把Pydantic模型转成JSON Schema注入到函数的parameters字段里模型只生成符合schema的json而不是自由发挥的文本。这一步的价值在于格式约束从“prompt里的建议”变成了“模型推理时的硬约束”。模型在函数调用模式下生成的内容必须匹配schema字段缺失和类型错误的概率会低得多。实测下来同一个抽取任务用函数调用模式相比纯文本prompt模式解析失败率能下降一个数量级。如果你用的模型不支持函数调用Llamaindex会退回到纯文本模式即把schema渲染成JSON示例塞进prompt。这时候format_instructions的质量就很重要建议在prompt里显式给出一个“好”的输出示例比只给字段定义要稳。3.2 解析失败时会发生什么即使有函数调用解析失败仍然可能发生尤其是在模型上下文过长、输出接近max_tokens边界时。常见的失败有这几类模型截断输出JSON不完整json.loads直接抛JSONDecodeError。字段类型不匹配Pydantic抛出ValidationError。嵌套模型里的数组字段为空导致下游业务拿到空列表还以为是数据缺失。我在代码里一般会包一层异常处理from pydantic import ValidationError from llama_index.core.output_parsers.base import OutputParserException try: parsed parser.parse(raw_output) except OutputParserException as e: # 记录原始输出方便人工检查 logger.error(fstructured parse failed, raw{raw_output}, exc_infoe) # 可以重试一次加上“请严格按要求输出”的补充提示 ... except ValidationError as e: logger.error(fpydantic validation failed: {e}) ...有一种情况特别值得注意如果同一个输入反复解析失败千万别第一时间怀疑模型能力先回头检查Pydantic字段的description是否写清楚了。我遇到过一次比较典型的问题字段叫summarydescription写的是“内容摘要”模型经常把整段原文塞进去导致超出长度限制。后来把description改成“用不超过50个字概括这段文本的核心观点”问题立刻缓解。LLM是非常依赖指令精确度的字段描述不够明确它就会按自己的理解发挥。3.3 结构化输出分类任务的一个技巧如果你需要的是分类而不是抽取可以充分利用Pydantic的Literal类型把候选类别直接写死在字段类型里。from typing import Literal class FeedbackCategory(BaseModel): category: Literal[bug, feature_request, question, other] Field( description用户反馈的分类 ) confidence: float Field(description置信度0到1之间)这比在prompt里写“请从以上类别中选择一个”要强得多。因为Literal会生成枚举约束模型只能从给定的选项里选从根本上杜绝了“发明新类别”的可能。从热词看最近“分类评估”这个话题讨论度不低其实在Llamaindex里就是Literal加一个评估器的事后面会提到评估器怎么接。4. 评估不是“跑个分”是三类指标的协同配合4.1 很多人对“评估”有个误解先说一个类比。很多人搜“beyond compare 30天评估期已结束”、“winrar去掉评估版本”这个“评估”指的是软件试用版试用期。但RAG系统里的“评估”完全是另一码事它指的是用一套可量化的指标判断当前系统的输出质量是否达标。这个误解导致不少项目组在演示时觉得“效果不错”一上线就被用户吐槽“答案在胡编”。原因在于RAG链路是“检索 生成”两个环节叠加。最终答案错了可能是检索没召回相关文档也可能是生成阶段模型没基于检索结果作答。如果只凭人的感觉判断“答得好不好”根本定位不到瓶颈。Llamaindex的llama_index.core.evaluation模块把这件事拆成了几个明确的评估器我用得最多的是下面三个评估器解决的问题一句话解释FaithfulnessEvaluator答案是否忠于检索到的上下文检测“幻觉”上下文里没有的信息模型有没有瞎编RelevancyEvaluator检索到的上下文是否与用户问题相关检测“检索失效”召回了一堆不相关内容答案自然歪CorrectnessEvaluator答案与标准参考答案是否一致检测“最终正确性”需要一份参考标准这三个指标不是替代关系是互补关系。正确答案可以从错误的上下文里侥幸生成忠实度高的答案也可能因为检索资料不足而不正确。所以我习惯把它们当成一个评估组合来看而不是单独看某一个。4.2 单个评估器实操以FaithfulnessEvaluator为例下面是FaithfulnessEvaluator最小可用的示例。它内部会用LLM判断“生成的回答”是不是完全由“上下文”支撑如果回答里出现了上下文没有的信息评估器会打回passingFalse。from llama_index.core.evaluation import FaithfulnessEvaluator from llama_index.core.indices.query.query_transform.base import DecomposeQueryTransform from llama_index.llms.openai import OpenAI # 评估本身也是一次LLM调用用便宜模型就够了 llm OpenAI(modelgpt-4o-mini) evaluator FaithfulnessEvaluator(llmllm) # 注意是 evaluate_response不是 evaluate result await evaluator.aevaluate_response( queryXX乐队是哪一年成立, responseresponse, # 这是 query_engine 返回的 Response 对象 ) print(result.passing) # True / False print(result.score) # 0.0 ~ 1.0 或者 None取决于评估器实现 print(result.feedback) # 具体判断理由可能有人会问为什么评估器本身也要用LLM这不是嵌套调用吗是的LLM-as-judge是目前主流的评估方式。它的优势是能理解语义能判断“这句话虽然表述不同但意思一致”短板是有token成本、有一定抖动。我在项目里的做法是评估用一个更便宜的模型统一跑完所有测试case能容忍少量误判毕竟看的是整体通过率趋势。4.3 三个评估器一起上的完整链路RelevancyEvaluator的用法几乎一样它把response替换成retrieved_nodes相关的查询结果判断“检索回来的上下文”和“用户问题”是否相关。CorrectnessEvaluator则要求提供reference参考回答判断模型答案和参考答案是否语义一致。真实项目里我不会只跑一个评估器而是把它们打包成一个BatchEvalRunner同一批query全部跑一遍。下一章就讲这个。5. 把评估跑起来批量Runner与评估集构建5.1 用BatchEvalRunner批量执行手动一条一条评估太慢了Llamaindex提供了BatchEvalRunner来批量跑多个评估器。from llama_index.core.evaluation import ( BatchEvalRunner, CorrectnessEvaluator, FaithfulnessEvaluator, RelevancyEvaluator, ) faithfulness_evaluator FaithfulnessEvaluator(llmllm) relevancy_evaluator RelevancyEvaluator(llmllm) correctness_evaluator CorrectnessEvaluator(llmllm) runner BatchEvalRunner( { faithfulness: faithfulness_evaluator, relevancy: relevancy_evaluator, correctness: correctness_evaluator, }, show_progressTrue, )准备好一批query后调用aevaluate_queriesqueries [ XX乐队哪一年成立, 这张专辑的制作人是谁, 乐队在2019年发行了什么作品, ] eval_results await runner.aevaluate_queries( query_enginequery_engine, queriesqueries, ) # eval_results 是 dictkey 是评估器名字value 是 EvaluationResult 列表 for metric_name, results in eval_results.items(): passing sum(r.passing for r in results if r.passing is not None) total len(results) print(f{metric_name}: {passing}/{total} passed, rate{passing / total:.2%})这里有一个要注意的点aevaluate_queries是异步的。如果你在Jupyter Notebook里跑需要先await或者用nest_asyncio处理在普通Python脚本里跑建议用asyncio.run(...)包一层。我第一次跑的时候直接同步调用evaluate_queries发现有些事件循环嵌套的问题后来统一改成异步写法才稳定。5.2 评估集从哪来文档改写成问题 人工抽检评估集的构建是最容易被糊弄的环节但也是最值得投入时间的。没有真实用户query可以先基于文档内容人工构造一批“黄金问题”。我习惯从三个维度构造问题类型例子评估目的单跳事实题“XX公司总部在哪”验证基础检索能力多跳综合题“A项目用了哪些技术栈和B项目有什么重叠”验证多文档综合能力否定/边界题“文档里有没有提到预算”验证模型会不会在无依据时拒绝回答数量上30到50条起步就够了不需要一上来就搞几百条。关键是每条都要人工标注“参考回答”或“期望行为”这样才能给CorrectnessEvaluator提供reference。如果预算允许我强烈建议评估集里混入5%左右的“超纲问题”也就是文档里完全没有答案的问题。这类问题最能暴露“幻觉”——一个好的RAG系统应该回答“未在文档中找到相关信息”而不是编一个像模像样的答案。我的经验是很多项目在单跳事实题上通过率能到90%一加上超纲问题直接掉到50%以下幻觉问题立刻现形。5.3 别忘了跑一次基线对比最后还有一个很容易被跳过的步骤基线对比。我见过太多团队拿着一个精调过的RAG链路跑出90%准确率心里很爽但完全不知道这个90%意味着什么。没有对比你根本不知道这90%里有多少得益于检索、多少得益于生成、多少是因为问题本身太简单。我的做法是同一个评估集分别跑最简单的向量检索问答不加任何rerank、prompt优化加了各种优化后的完整链路一个纯LLM直接回答不检索作为对照组。然后比较三个实验的faithfulness、relevancy、correctness通过率。这一步做完就能清楚地看到每一项优化到底带来了多少增益也能判断哪项优化其实可有可无。6. 把这两套机制沉淀进项目日常6.1 结构化输出的后续使用建议结构化输出的价值在于让下游程序“不再猜”。我在项目里落地时一般会把抽取结果直接写入数据库然后再加一个“字段来源可追溯”的设计抽取出的结构化字段同时保留一条原文索引。这样做的好处是当业务方怀疑某个字段抽错了可以直接跳到原文段落复核。另外结构化输出的retry逻辑一定要有上限。建议单次最多重试两次超过就标记为“抽取失败”交给人工兜底。千万别让系统无限重试既浪费token又可能在坏数据上来回打转。6.2 评估结果怎么用起来评估不是跑完一轮就结束的。我在项目里固定每周跑一次全量评估集把三项指标通过率的变化画成趋势。谁动了prompt、谁换了embedding模型、谁加了新文档都可能导致指标波动。没有这套持续评估机制优化方向全靠拍脑袋问题回归了也发现不了。还有一个小技巧把评估结果里的feedback字段收集起来定期做一轮聚类。你会发现大模型的“反馈”往往能指出共性问题比如“回答引用了文档中未提及的年份”“上下文里缺少用户询问的具体字段”。这些反馈比单纯看通过率有用得多它能直接告诉你下一步该优化检索还是优化生成。6.3 这里有几个我自己的硬经验最后分享几个踩过坑之后的总结。第一结构化输出的Pydantic字段description一定写清楚这是成本最低、收益最高的改进点。很多“模型抽得不准”的问题根源是字段描述模糊。第二评估器的模型选择要和线上模型区分开。线上用大模型保证效果评估用小模型控制成本这是可以接受的。但注意小模型的判断不一定稳定如果某个case在两次评估中结果不一致不要惊慌多跑几次取众数即可。第三我不建议把CorrectnessEvaluator作为唯一指标。它强依赖参考回答的质量而参考回答本身是人写的也有主观偏差。FaithfulnessEvaluator和RelevancyEvaluator更像“体检指标”能从机制上发现系统性问题数据和参考回答容易构造普适性也更强。我在实际项目里最深的一点体会是结构化输出解决的是“机器能不能直接用”评估解决的是“效果到底行不行”。前者让LLM从“答题者”变成“接口实现者”后者让RAG系统从“感觉还可以”走向“有据可依”。如果能把这两件事揉进日常开发流程RAG应用才真正有底气推到生产环境。