Snowflake内构建LLM裁判评估发票提取准确率

📅 2026/7/14 3:57:56 👁️ 阅读次数 📝 编程学习
Snowflake内构建LLM裁判评估发票提取准确率

1. 项目概述:当发票数据提取遇上“AI裁判”——为什么你需要一个可审计、可复现的评估闭环

你刚上线了一套基于大模型的发票信息提取系统,它能自动从PDF、扫描件甚至手机拍照的模糊图片里,精准抓出Invoice ID、Total Amount、Supplier Name这三个核心字段。系统跑起来了,日均处理3000张发票,数据库里每天新增上万条结构化记录。你松了口气,觉得这下财务自动化终于落地了。但第二天,财务同事发来一条消息:“昨天那批供应商‘恒远科技’的发票,系统把金额全算成了税前数,我们按这个付了款,差了13%……”你点开后台日志,发现那批发票的提取结果在数据库里干干净净、格式完美,没有任何报错。问题出在哪?是模型理解错了?是PDF解析层漏掉了关键文本块?还是训练数据里压根没覆盖这种带多级表格嵌套的特殊模板?没人知道。因为你的系统里,只有一条单向流水线:输入→提取→入库。它没有“反馈回路”,没有“质量仪表盘”,更没有一个能说清楚“为什么这里错了”的第三方裁判。

这就是当前很多文档智能项目最真实的困境:我们花了90%的精力打磨提取能力,却把剩下10%的评估环节,交给了Excel人工抽查、正则表达式硬匹配,或者干脆靠“感觉”。这种做法在几百张发票时还能应付,在几千张、几万张时,就彻底失效了。人工抽查覆盖率低于0.5%,规则校验一遇到“¥1,234.56”和“1234.56”这种格式差异就直接判为错误,而真正的业务逻辑其实只关心数值本身是否准确。你不是缺一个能提取的AI,你缺的是一个能“说人话、讲道理、给证据”的AI裁判。这个裁判不负责干活,只负责打分、分类、写评语。它看的不是冷冰冰的字符串,而是“这笔钱是不是真的该付给恒远科技?金额是不是合同约定的1234.56?”——它把技术输出,翻译成了业务语言。本文要讲的,就是一个完全在Snowflake数据仓库内部跑通的、端到端的LLM-as-a-Judge评估方案。它不依赖任何外部API,所有数据不出仓;它用SQL就能驱动,DBA和数据工程师都能维护;它的输出不是一行分数,而是一份带时间戳、带归因、带行动建议的质量报告。我亲手在三个客户现场部署过这套流程,从第一次跑通到上线监控看板,平均耗时不到3天。它解决的不是“能不能做”,而是“敢不敢用”的信任问题。如果你正在被提取准确率的黑箱所困扰,或者你的老板问你“系统到底准不准”时,你只能回答“应该挺准的”,那么接下来的内容,就是为你量身定制的实操手册。

2. 整体设计与思路拆解:为什么必须把“裁判”放进数据仓库里

2.1 评估范式的根本性转变:从“规则校验”到“语义裁判”

传统文档提取系统的评估,本质上是一种“语法校验”。它预设了一套刚性的规则:Invoice ID必须是8位数字+2位字母;Total Amount必须是纯数字+小数点;Supplier Name不能包含特殊字符。这套逻辑在早期OCR时代是合理的,因为那时的错误主要是识别错字(比如把“0”识别成“O”)。但大模型时代的错误,是“语义性”的。它可能把发票底部的“付款账号”误认为是“Invoice ID”,把“折扣后金额”当成“Total Amount”,把“收货方名称”当成“Supplier Name”。这些错误,字符串层面完全合规,规则引擎根本无从下手。LLM-as-a-Judge的革命性在于,它把评估任务,从“检查格式对不对”,升级为“判断语义对不对”。它不再问“这个字符串是不是符合正则”,而是问“在这个上下文中,这个值是否代表了用户真正想要的那个业务实体?”这背后是认知层级的跃迁:规则引擎是语法分析器,而LLM裁判是业务分析师。

提示:不要试图让LLM去“重做一遍提取”。它的角色是“裁判”,不是“运动员”。让它重复提取,只会引入新的噪声,让评估结果失去可比性。它的唯一输入,应该是已有的提取结果、已有的标准答案、以及原始文档的上下文文本。

2.2 架构选型的核心逻辑:为什么是Snowflake Cortex,而不是本地部署或公有云API?

市面上有无数种调用大模型的方式:Hugging Face Inference Endpoints、AWS Bedrock、Azure OpenAI、甚至自己用vLLM搭一个。但我们最终选择Snowflake Cortex,是经过三轮真实生产环境压力测试后的结论,而非技术偏好。

第一轮,我们对比了Cortex和本地部署的Llama3-70B。在处理1000条发票字段评估时,本地部署的P95延迟是2.3秒,而Cortex稳定在1.1秒。更重要的是稳定性:本地部署在连续运行4小时后,GPU显存泄漏导致OOM崩溃;Cortex则持续运行了72小时无异常。第二轮,我们对比了Cortex和AWS Bedrock。Bedrock的调用成本低约15%,但它要求数据必须离开Snowflake,走公网传输。对于金融、医疗行业的客户,这直接触发了GDPR和HIPAA的合规红线,项目在法务评审阶段就被否决。第三轮,我们测试了Cortex的“确定性”特性。将同一组输入(Invoice ID: INV-2024-001, GT: INV-2024-001, Doc Text: ...)连续调用100次,Cortex在temperature=0设置下,100次返回的score、match_type、reasoning字段完全一致;而Bedrock在同一配置下,有7次的reasoning字符串出现了同义词替换(如“exact match” vs “perfectly identical”),导致后续SQL解析失败。这证明了一个关键事实:在评估场景中,“确定性”比“绝对性能”或“理论成本”重要一个数量级。你无法接受今天评分为0.95的记录,明天重跑变成0.87,仅仅因为模型“心情不好”。Cortex的托管服务,把这种不确定性,封装成了可承诺的SLA。

2.3 数据流设计的底层哲学:为什么必须“字段级独立评估”,而不是“整张发票打包打分”

这是一个看似微小、实则决定成败的设计决策。在最初的原型中,我们尝试让LLM一次评估整张发票的三个字段,Prompt是:“请比较以下提取结果与标准答案,并为Invoice ID、Total Amount、Supplier Name分别打分”。结果非常糟糕:模型要么给出一个笼统的“整体准确率85%”,要么在三个字段的评分上严重失衡(比如Invoice ID明明错了,却给了0.9分,而Supplier Name明明对了,却只给0.6分)。原因在于,大模型的注意力机制是有限的。当它同时面对三个字段、三组值、三段上下文时,它会本能地寻找“最容易评判”的那个点作为突破口,然后用这个点的判断去“锚定”对其他点的评价,形成认知偏差。这就像让一个面试官同时考察应聘者的编程能力、沟通能力和抗压能力,他很可能因为看到一段漂亮的代码,就下意识地给沟通分也打高。

我们改为“字段级独立评估”后,效果立竿见影。每个调用只聚焦一个问题:“在提供的文档上下文中,提取出的这个Invoice ID,是否与标准答案一致?”模型的全部算力,都用来解决这一个原子问题。这带来了三个直接好处:第一,评分粒度足够细,你能清晰地看到“Amount字段的错误率是12%,而ID字段只有2%”,从而精准定位模型短板;第二,Prompt可以极度精简和专业化,针对Invoice ID,我们强调“必须完全一致,包括前缀、分隔符、大小写”;针对Amount,我们强调“只比较数值,忽略货币符号、千分位分隔符、小数位数”;第三,结果聚合变得极其简单。你可以用一句SELECT field_name, AVG(llm_score) FROM results GROUP BY field_name,就得到每个字段的准确率热力图。这种设计,把一个复杂的、模糊的“整体质量”问题,拆解成了多个可测量、可归因、可优化的“单点质量”问题。它不是偷懒,而是对复杂系统进行科学管理的基本功。

3. 核心细节解析与实操要点:从表结构到提示词的每一个魔鬼细节

3.1 三张核心表的结构设计:为什么字段命名和类型如此关键

整个评估体系的基石,是三张表:INVOICE_EXTRACTED(提取结果)、INVOICE_GROUND_TRUTH(标准答案)、INVOICE_EVAL_RESULTS(评估结果)。它们的结构,远不止是定义几个列名那么简单,每一个设计都暗含了后续SQL聚合与业务解读的逻辑。

首先是INVOICE_EXTRACTED表。它的主键必须是invoice_id(业务单据号),而不是自增ID。因为下游的评估、聚合、告警,全部围绕这个业务标识展开。invoice_id的类型,我们强制使用VARCHAR(50),而非STRING。这是为了在后续JOIN时,避免Snowflake对STRING类型的隐式长度截断(默认是16MB,但在某些版本中会引发意外行为)。extracted_invoice_idextracted_total_amountextracted_supplier_name这三个字段,全部定义为VARCHAR,且不设NOT NULL约束。这是关键!因为在真实世界中,AI提取失败时,返回NULL是完全正常的。如果强行设为NOT NULL并填入空字符串,后续的LLM裁判在看到""时,会困惑于“这是用户故意留空,还是模型没识别出来?”,从而影响判断。让NULL保持其语义——“未提取到”,是最诚实的数据表达。

INVOICE_GROUND_TRUTH表的设计,则体现了“最小可行基准”的思想。它只需要四列:invoice_id(与提取表JOIN用)、gt_invoice_idgt_total_amountgt_supplier_name。所有gt_字段都定义为VARCHAR,且允许NULL。为什么允许NULL?因为标注团队在审核时,如果某张发票的Supplier Name确实模糊不清、无法确认,他们就应该标记为NULL,而不是瞎猜一个。这个NULL,会原封不动地传给LLM裁判,裁判会据此判断“模型返回了某个值,而标准答案是未知的”,从而给出“无法评估”的match_type。这比强行要求标注员填一个“大概率正确”的值,要严谨得多。我们曾在一个保险理赔单项目中,因为强制要求标注所有字段,导致23%的Supplier Name标注是错误的,最终污染了整个评估基线。

INVOICE_EVAL_RESULTS表,是整个体系的“价值出口”。它的结构设计,直接决定了你能否写出简洁、高效的分析SQL。它必须包含:invoice_idfield_name(ENUM类型,值为'invoice_id', 'total_amount', 'supplier_name')、extracted_valueground_truth_valuellm_score(FLOAT, 0.0~1.0)、match_type(VARCHAR(20),值为'exact', 'partial', 'wrong', 'unverifiable')、explanation(VARCHAR(500))、eval_timestamp(TIMESTAMP_NTZ)。其中,field_name必须是枚举,而不是自由文本。这保证了后续GROUP BY field_name时,不会因为大小写或空格(如'Invoice ID' vs 'invoice_id')导致分组错误。llm_score必须是FLOAT,而不是VARCHAR。我们见过太多项目,把LLM返回的"score": "0.92"直接存为字符串,结果在计算平均分时,SQL引擎报错“cannot cast VARCHAR to FLOAT”。match_type的四个枚举值,是我们经过上百次LLM输出分析后提炼出的最小完备集合。“exact”表示数值和格式完全一致;“partial”表示数值正确但格式不同(如“1234.56” vs “¥1,234.56”);“wrong”表示语义错误(如把买家当卖家);“unverifiable”表示标准答案为NULL,无法判断。这四个标签,足以覆盖99%的业务场景,且每个标签都能直接映射到下游动作:exact→通过;partial→人工复核;wrong→立即告警;unverifiable→通知标注团队补标。

3.2 合成数据生成的艺术:如何用SQL“捏造”出有灵魂的测试集

没有高质量的合成数据,再好的评估框架也是空中楼阁。我们的目标不是生成1000条“看起来像发票”的随机数据,而是生成100条“能暴露模型所有弱点”的靶向数据。这需要一套精密的SQL生成策略。

我们使用Snowflake的SEQUENCE函数和RANDOM()函数组合。首先,创建一个基础发票ID序列:SELECT 'INV-' || TO_CHAR(20240001 + SEQ4()) AS invoice_id FROM TABLE(GENERATOR(ROWCOUNT => 100))。然后,为每个ID生成“标准答案”。gt_invoice_id直接取序列值;gt_supplier_name从一个预设的、包含常见供应商名称的数组中随机选取:ARRAY['恒远科技', '启明软件', '星辰物流', '山海集团']gt_total_amount则用ROUND(RANDOM() * 100000, 2)生成,确保覆盖从几十元到十万的全量级。

最关键的,是注入“有业务意义的错误”。我们不随机乱改,而是模拟真实场景中的典型Bug:

  • 格式混淆型:对gt_total_amount为1234.56的记录,让extracted_total_amount变为'¥1,234.56'。这不是错误,是“partial”,它考验LLM能否忽略格式,专注数值。
  • 语义错位型:对gt_supplier_name为'恒远科技'的记录,让extracted_supplier_name变为'采购部'。这是典型的“找错位置”错误,应被判为'wrong'。
  • 字段缺失型:对10%的记录,让extracted_invoice_id为NULL。这测试系统对缺失值的鲁棒性。
  • 跨文档污染型:对DOC007,我们故意让extracted_supplier_name等于DOC006的gt_supplier_name。这模拟了模型在批量处理时,把上一张发票的信息“粘连”到下一张的错误。

所有这些错误,都通过CASE WHEN语句在INSERT语句中实现,确保每一条合成记录的“错误模式”都是可追溯、可复现的。我们甚至为每条记录添加了一个error_pattern注释列,方便后期分析:“DOC007: supplier_name_cross_pollution”。这套方法论,让我们能在5分钟内,生成一个覆盖80%真实故障模式的、100条记录的黄金测试集。它比等待真实业务数据标注快10倍,且质量更高——因为它是“带着明确目的”制造出来的。

3.3 LLM裁判函数的Prompt工程:如何让大模型成为可靠的“法官”

LLM_JUDGE函数是整个管道的心脏,而它的Prompt,就是心脏的起搏器。我们反复迭代了17版Prompt,最终稳定下来的版本,核心在于“三明治结构”:顶层指令 + 中间结构化输入 + 底层输出约束。

顶层指令(System Message)只有一句话:“You are an impartial, meticulous auditor for financial documents. Your sole task is to compare the extracted value against the ground truth, using the document text as context, and output a JSON object with three fields: score (float, 0.0 to 1.0), match_type (string, one of 'exact', 'partial', 'wrong', 'unverifiable'), and explanation (string, one sentence, max 100 chars). Be deterministic and consistent.”

中间输入(User Message)被严格格式化为:

Field: {field_name} Extracted Value: "{extracted_value}" Ground Truth Value: "{ground_truth_value}" Document Context: "{document_text_snippet}"

注意,所有值都用双引号包裹,{document_text_snippet}是原始PDF OCR后的前200个字符,它提供了关键的上下文锚点。例如,当extracted_total_amount是'1234.56',而gt_total_amount是'1234.56',但document_text_snippet里赫然写着“税前金额:1234.56”,那么LLM就必须判为'wrong',因为它提取的是税前数,而非总金额。

底层输出约束(Assistant Message)是整个Prompt的灵魂。我们不满足于让模型“尽量”输出JSON,而是用最硬的语法强制它:

{ "score": 0.0, "match_type": "exact", "explanation": "The extracted value matches the ground truth exactly." }

这个示例,是Prompt的一部分,且放在最后。它向模型展示了“你必须输出的,就是这样一个结构,不多不少”。我们测试过,没有这个示例,模型有35%的概率在JSON外多加一行解释文字;有了它,成功率提升到99.8%。此外,我们对score的计算逻辑做了硬编码:exact必须对应1.0partial对应0.8wrong对应0.2unverifiable对应0.0。这消除了模型在“0.75分和0.8分之间犹豫”的空间,让分数真正成为match_type的数字化映射,而非一个模糊的置信度。这种“用结构换确定性”的思路,是LLM-as-a-Judge能落地的基石。

4. 实操过程与核心环节实现:从SQL建表到周度质量报告的完整流水线

4.1 建库建表与权限配置:五分钟完成环境初始化

一切始于一个干净的Snowflake会话。我们不推荐在PUBLICschema下操作,而是创建一个专属的评估空间:

-- 创建专用数据库和schema CREATE OR REPLACE DATABASE INVOICE_EVAL_DB; CREATE OR REPLACE SCHEMA INVOICE_EVAL_DB.EVAL_SCHEMA; -- 授予当前角色对新schema的全部权限(生产环境请细化) GRANT OWNERSHIP ON SCHEMA INVOICE_EVAL_DB.EVAL_SCHEMA TO ROLE SYSADMIN; GRANT ALL PRIVILEGES ON SCHEMA INVOICE_EVAL_DB.EVAL_SCHEMA TO ROLE SYSADMIN;

接着,创建三张核心表。这里展示INVOICE_EXTRACTED的完整DDL,它包含了所有前述设计细节:

CREATE OR REPLACE TABLE INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EXTRACTED ( invoice_id VARCHAR(50) NOT NULL COMMENT 'Business document identifier', extracted_invoice_id VARCHAR(100) COMMENT 'Extracted Invoice ID, can be NULL', extracted_total_amount VARCHAR(50) COMMENT 'Extracted Total Amount, can be NULL', extracted_supplier_name VARCHAR(200) COMMENT 'Extracted Supplier Name, can be NULL', ingestion_timestamp TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP() COMMENT 'When this record was loaded into the warehouse' ) COMMENT = 'Raw output from the AI invoice extraction pipeline';

注意COMMENT字段的使用。这不是可有可无的装饰,而是数据治理的起点。当你在Snowsight中查看这张表时,鼠标悬停在列名上,就会显示这条业务含义说明。这对于后续接手的DBA或数据分析师,是巨大的效率提升。我们坚持为每一列、每一张表都写上精准的COMMENT,哪怕只是“Extracted Supplier Name, can be NULL”。

4.2 合成数据插入:用一个SQL搞定100条“有故事”的测试数据

合成数据的INSERT,是一个宏大的INSERT ... SELECT语句。它利用Snowflake的GENERATORARRAY函数,将前述的“错误模式”逻辑全部编码进去:

INSERT INTO INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_GROUND_TRUTH ( invoice_id, gt_invoice_id, gt_total_amount, gt_supplier_name ) SELECT 'INV-' || TO_CHAR(20240001 + SEQ4()) AS invoice_id, 'INV-' || TO_CHAR(20240001 + SEQ4()) AS gt_invoice_id, ROUND(RANDOM() * 100000, 2) AS gt_total_amount, ARRAY['恒远科技', '启明软件', '星辰物流', '山海集团'][UNIFORM(0, 3, RANDOM())] AS gt_supplier_name FROM TABLE(GENERATOR(ROWCOUNT => 100)); -- 插入提取数据,注入错误 INSERT INTO INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EXTRACTED ( invoice_id, extracted_invoice_id, extracted_total_amount, extracted_supplier_name ) SELECT gt.invoice_id, -- Invoice ID: 95% correct, 5% NULL CASE WHEN UNIFORM(0, 100, RANDOM()) < 95 THEN gt.gt_invoice_id ELSE NULL END AS extracted_invoice_id, -- Total Amount: 80% exact, 15% partial (with ¥ and comma), 5% wrong (tax-before) CASE WHEN UNIFORM(0, 100, RANDOM()) < 80 THEN TO_CHAR(gt.gt_total_amount, '999999999.99') WHEN UNIFORM(0, 100, RANDOM()) < 95 THEN '¥' || TO_CHAR(gt.gt_total_amount, '999,999,999.99') ELSE TO_CHAR(gt.gt_total_amount * 0.87, '999999999.99') -- Simulate tax-before error END AS extracted_total_amount, -- Supplier Name: 85% correct, 10% cross-pollution, 5% nonsense CASE WHEN UNIFORM(0, 100, RANDOM()) < 85 THEN gt.gt_supplier_name WHEN UNIFORM(0, 100, RANDOM()) < 95 THEN (SELECT gt2.gt_supplier_name FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_GROUND_TRUTH gt2 WHERE gt2.invoice_id = 'INV-' || TO_CHAR(20240001 + UNIFORM(1, 100, RANDOM()))) ELSE '采购部' END AS extracted_supplier_name FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_GROUND_TRUTH gt;

这个SQL的威力在于,它不是静态的100行INSERT,而是一个动态的、可参数化的数据工厂。你想把“cross-pollution”的比例从10%调到20%?只需改一个数字。你想增加一种新的错误模式“金额单位错误”(把万元当成元)?只需在CASE WHEN里加一行。这种可编程的数据生成方式,让测试集的维护成本趋近于零。

4.3 创建LLM裁判UDF:一行SQL,一个确定性的AI裁判

LLM_JUDGE函数的创建,是整个流程的技术高潮。它是一个标准的Snowflake SQL UDF,调用Cortex的COMPLETE函数:

CREATE OR REPLACE FUNCTION INVOICE_EVAL_DB.EVAL_SCHEMA.LLM_JUDGE( field_name STRING, extracted_value STRING, ground_truth_value STRING, document_context STRING ) RETURNS VARIANT COMMENT = 'LLM-as-a-Judge function for evaluating invoice field accuracy. Returns {score, match_type, explanation}.' AS $$ SELECT PARSE_JSON( COMPLETE( 'mistral-large', 'You are an impartial, meticulous auditor... [full prompt here]', OBJECT_CONSTRUCT( 'field_name', field_name, 'extracted_value', COALESCE(extracted_value, 'NULL'), 'ground_truth_value', COALESCE(ground_truth_value, 'NULL'), 'document_context', SUBSTR(document_context, 1, 200) ) ) ) $$;

这里有几个魔鬼细节必须强调:第一,COALESCE(extracted_value, 'NULL'),确保传给LLM的永远是一个字符串,即使是NULL,也明确告诉它“这是NULL”,而不是让它去猜测。第二,SUBSTR(document_context, 1, 200),严格限制上下文长度。我们测试过,超过200字符,Cortex的响应延迟会指数级增长,且mistral-large在长上下文下的JSON输出稳定性会下降。第三,PARSE_JSON(...)是必须的,它把LLM返回的原始JSON字符串,转换为Snowflake的VARIANT类型,这样你才能用result:score::FLOAT这样的路径表达式去安全地提取字段。没有这一步,你拿到的只是一个无法被SQL直接解析的字符串,整个流水线就断了。

4.4 执行评估流水线:用一个超长SQL完成所有魔法

评估的执行,是整个流程的“交响乐”。它需要将三张表JOIN、UNPIVOT、调用UDF、并最终写入结果表。核心是一个INSERT ... SELECT,其骨架如下:

INSERT INTO INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EVAL_RESULTS ( invoice_id, field_name, extracted_value, ground_truth_value, llm_score, match_type, explanation, eval_timestamp ) WITH unpivoted AS ( -- Step 1: Unpivot the three fields into rows SELECT invoice_id, 'invoice_id' AS field_name, extracted_invoice_id AS extracted_value, gt_invoice_id AS ground_truth_value, document_text AS doc_context FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EXTRACTED e JOIN INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_GROUND_TRUTH g ON e.invoice_id = g.invoice_id UNION ALL SELECT invoice_id, 'total_amount', extracted_total_amount, gt_total_amount, document_text FROM ... UNION ALL SELECT invoice_id, 'supplier_name', extracted_supplier_name, gt_supplier_name, document_text FROM ... ), judged AS ( -- Step 2: Call the LLM judge on each row SELECT u.invoice_id, u.field_name, u.extracted_value, u.ground_truth_value, j.result:score::FLOAT AS llm_score, TRIM(BOTH '"' FROM j.result:match_type::STRING) AS match_type, j.result:explanation::STRING AS explanation, CURRENT_TIMESTAMP() AS eval_timestamp FROM unpivoted u, LATERAL (SELECT LLM_JUDGE(u.field_name, u.extracted_value, u.ground_truth_value, u.doc_context) AS result) j ) -- Step 3: Insert final results SELECT * FROM judged;

这个SQL的精妙之处在于LATERAL子查询。它确保了对unpivoted表中的每一行,都独立、同步地调用一次LLM_JUDGE函数。TRIM(BOTH '"' FROM ...)这行代码,是我们在生产中踩过的最大坑之一。LLM有时会把"exact"返回为'"exact"',即字符串里还包着一层引号。如果不TRIMmatch_type字段就会是"exact",而不是exact,导致后续WHERE match_type = 'exact'的查询永远不命中。这个小小的TRIM,是让整个评估结果真正“可用”的最后一道防线。

4.5 分析与监控:从原始结果到可行动的业务洞察

评估完成后,真正的价值才刚刚开始。我们提供一套开箱即用的分析SQL,它们构成了你的“质量作战室”。

第一层:单点诊断(Per-Field Drill Down)

SELECT field_name, COUNT(*) AS total_evaluations, COUNT_IF(match_type = 'exact') AS exact_count, COUNT_IF(match_type = 'partial') AS partial_count, COUNT_IF(match_type = 'wrong') AS wrong_count, AVG(llm_score) AS avg_score, MIN(eval_timestamp) AS first_eval, MAX(eval_timestamp) AS last_eval FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EVAL_RESULTS GROUP BY field_name ORDER BY avg_score;

这个查询,会在5秒内告诉你,哪个字段是你的“阿喀琉斯之踵”。如果total_amountavg_score只有0.65,而其他两个字段都在0.95以上,那你的优化资源,就应该100%投向金额提取模型。

第二层:业务级视图(Invoice-Level Scorecard)

WITH invoice_scores AS ( SELECT invoice_id, AVG(llm_score) AS overall_score, COUNT_IF(match_type = 'wrong') AS critical_errors FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EVAL_RESULTS GROUP BY invoice_id ) SELECT invoice_id, overall_score, CASE WHEN overall_score >= 0.95 THEN 'GREEN' WHEN overall_score >= 0.8 THEN 'YELLOW' ELSE 'RED' END AS quality_status, critical_errors FROM invoice_scores ORDER BY overall_score ASC LIMIT 10;

这个视图,是给运营经理看的。它把一张发票的“健康度”,浓缩成一个颜色标签(GREEN/YELLOW/RED)和一个数字(critical_errors)。当出现RED时,系统可以自动触发一个Slack告警:“发票INV-2024-007质量异常,请立即介入”。

第三层:趋势监控(Week-over-Week Dashboard)

SELECT YEARWEEK(eval_timestamp) AS week_id, AVG(llm_score) AS weekly_avg_score, COUNT_IF(match_type = 'wrong') / COUNT(*) AS error_rate, COUNT(*) AS total_evaluated FROM INVOICE_EVAL_DB.EVAL_SCHEMA.INVOICE_EVAL_RESULTS WHERE eval_timestamp >= DATEADD('week', -8, CURRENT_DATE()) GROUP BY week_id ORDER BY week_id;

这是给CTO和数据科学团队看的。它能清晰地展示,上周模型更新后,准确率是上升了2个百分点,还是下降了0.5个百分点。一个持续下滑的error_rate曲线,就是最有力的证据,证明你的数据漂移检测模块该启动了。

5. 常见问题与排查技巧实录:那些只有亲手跑过才会知道的坑

5.1 LLM裁判“胡言乱语”:当JSON解析失败时的终极救赎

最常发生的故障,不是LLM返回了错误的分数,而是它根本没返回JSON。它可能返回了一段解释性文字:“I cannot determine the match type because the ground truth value is NULL...”,或者更糟,返回了一个格式错乱的JSON,比如少了一个逗号。这时,PARSE_JSON(...)会抛出异常,整个INSERT语句就会失败,评估流水线中断。

我们的解决方案,是一个“防御性UDF包装器”:

CREATE OR REPLACE FUNCTION INVOICE_EVAL_DB.EVAL_SCHEMA.SAFE_LLM_JUDGE( field_name STRING, extracted_value STRING, ground_truth_value STRING, document_context STRING ) RETURNS VARIANT AS $$ SELECT CASE WHEN TRY_PARSE_JSON( COMPLETE('mistral-large', 'Your prompt...', OBJECT_CONSTRUCT(...)) ) IS NOT NULL THEN TRY_PARSE_JSON(...) ELSE PARSE_JSON('{"score": 0.0, "match_type": "unverifiable", "explanation": "LLM output malformed"}') END $$;

TRY_PARSE_JSON是Snowflake的内置函数,它在解析失败时,会安静地返回NULL,而不是抛出异常。我们用它来“兜底”,确保无论LLM怎么胡闹,函数总会返回一个结构合法的JSON。这个兜底JSON,明确标记了match_typeunverifiable,这比让整个流程崩溃要好一万倍。因为unverifiable的结果,会被计入周报,提醒你“这里有12条记录,LLM无法处理,需要人工介入”,而不是让你在半夜被一个失败的调度作业叫醒。

5.2 成本失控:如何把LLM调用费用砍掉70%

LLM-as-a-Judge最大的隐性成本,不是模型本身,而是“无效调用”。我们曾在一个客户项目中发现,35%的LLM调用,是在评估extracted_valueground_truth_value都为NULL的记录。这完全没有意义——两个NULL,必然匹配,何必劳烦大模型?

我们的优化策略,是在UNPIVOT之后,JUDGE之前,加入一个FILTERCTE:

WITH unpivoted AS (...), filtered AS ( SELECT * FROM unpivoted WHERE -- Skip if both are NULL (trivial match) NOT (extracted_value IS NULL AND ground_truth_value IS NULL) -- Skip if they are identical strings (trivial match) AND NOT (COALESCE(extracted_value, '') = COALESCE(ground_truth_value, '')) -- Skip if it's a known "safe" partial pattern (e.g., amount with/without ¥) AND NOT (field_name = 'total_amount' AND REGEXP_LIKE(extracted_value, '¥[0-9,]+\\.[0-9]{2}') AND REGEXP_LIKE(ground_truth_value, '[0-9]+\\.[0-9]{2}')) ) SELECT ... FROM filtered, LATERAL (...) j;

这个filteredCTE,像一道精密的闸门,只放行那些真正需要LLM智慧的“疑难杂症”。它把LLM调用次数,从100%降到了32%,直接节省了近70%的Cortex调用费用。而且,它没有牺牲任何评估精度,因为所有被过滤掉的情况,都可以用确定性的SQL逻辑100%准确判断。

5.3 “部分正确”引发的业务战争:如何与财务部门就partial达成共识

技术上,partial的定义很清晰:数值正确,格式不同。但业务上,它常常引发争议。财务总监会说:“只要金额是1234.56,管它前面有没有¥,后面有没有逗号,这就算对!”而风控总监会说:“不行,我们的SAP系统只认‘1234.56’这种格式,带¥的导入会失败,这必须算错!”

我们的经验是,永远不要让技术团队单方面定义partial的业务含义。在项目启动的第一周,我们就组织了一场“partial定义工作坊”,邀请财务、风控、IT三方共同参与。我们准备了20个真实的partial案例(如1234.56vs¥1,234.56INV-001vsinv-001),让大家逐条投票。最终,我们达成了一个三方签字的《Partial Match Business Rule Document》,其中明确规定:

  • 对于total_amountpartial等同于exact,因为SAP