Jev Recipe / 文档处理

用 Jev 做文档分类:先拆分包,再判类型的两阶段管道

一封邮件带 3 份附件、一次扫描混入 8 页发票和 1 页合同:先用 Noul 判定页级边界拆开文档包,再用类型化 Choice 逐份分类——校准置信度自动分流复核队列。

搭建文档包两阶段管道的 6 个步骤

01动手写代码前先定义两条轴。拆分包轴:什么算「新文档的开始」——新的发票号、签名栏、版式切换。类型轴:发票、合同、收据、信函、其他。两条轴都用运营团队手工归档时已经在用的 criteria 词汇来写。
02state 保持精简、纯文本:只带抽取器产出的页级文本和真正影响判定的元数据——page_index、来源(邮件 / 扫描 / 传真)、是否跑过 OCR。Jev 读的是文本不是像素:模型里没有 OCR 也没有版面解析,抽取在前游完成,抽取结果就是输入。
03两阶段合成每页一次 evaluate 调用:Noul 问题判定分包边界(is_single_document、needs_ocr),doc_type Choice 给该页所属的文档段打类型标签。所有答案在单次约 100–500ms 前向推理中带校准置信度返回——页与页并行跑,文档包再大,墙钟时间也基本不变。
04分流策略写在代码里:doc_type 置信度 ≥ 0.85 自动归档,0.60–0.85 进复核队列,低于 0.60——加上所有「other」标签和所有 needs_ocr 页——升级人工。和置信度门控降级链同一套三车道纪律:切分点从标注文档包里推导,不拍脑袋。
05批量之前先算账:state 只按输入 token 计费($0.042/M),输出免费。一个 9 页文档包按每页约 300 token 算约 $0.0001,一万包的存量扫描不到一美元。生态里的公开数据点:1,018 篇研究论文全部分类只花了 $0.08。
06监控边界,而不只是标签:统计 is_single_document 落在争议带的频率,把运营的修正回流成标注样本,每季度重验阈值。新供应商的新版式就是一次分布漂移——和降级链要重校准的是同一件事。
schema / 文档包拆分分类判定契约
{
  "is_single_document": {
    "type": "noul",
    "instructions": "这一页是新文档的开始,还是上一页文档的延续?"
  },
  "needs_ocr": {
    "type": "noul",
    "instructions": "这一页的文本是否缺失或乱到无法可靠判定,需要先跑 OCR?"
  },
  "doc_type": {
    "type": "choice",
    "instructions": "把这份文档归入恰好一个类型",
    "criteria": {
      "invoice": "逐项计费:明细行、数量、总额、税、付款条款",
      "contract": "有约束力的协议:签约方、条款、期限日期、签名栏",
      "receipt": "支付完成凭证:已付标记、交易参考号、无待付金额",
      "letter": "人工往来沟通:称呼、正文、签名块",
      "other": "以上都不匹配且无把握——升级复核,不要硬塞标签"
    }
  }
}
把一页真实文本作为 state 发送,检查 is_single_document、needs_ocr 与 doc_type 的校准置信度。

文档类型轴:五个标签和一个设计好的兜底

分类质量从标签集开始。下面五个类型覆盖了绝大多数后台文档量;第六张卡最重要——一个设计好的「其他」,宁可升级也不要硬塞标签。tells 直接当 Choice schema 的 criteria 词汇用,轴保持精简:每多一个标签,概率质量就被多稀释一分。

发票

应收的钱——文档包里最常见的载荷。

  • 带数量和单价的明细行
  • 页眉或页脚里的发票号
  • 税额与应付总额
  • 付款条款:账期 30 天、到期日、收款账户
  • 开票方与收票方信息块

合同

有约束力的协议——风险高,值得单设复核车道。

  • 导言里有具名的签约方
  • 编号条款与定义章节
  • 期限、生效日、续约日
  • 签名栏含打印姓名和日期
  • 法律样版页眉与页码

收据

支付凭证——最容易和发票混淆的类型。

  • 「已支付」或交易完成标记
  • 终端或 POS 台头与门店信息
  • 明细行很短,没有付款条款
  • 卡号后四位或支付参考号
  • 无待付金额——已付清,余额为零

信函与往来邮件

人与人之间的沟通——封面邮件、备忘录、通知函。

  • 称呼与落款包着自由正文
  • 正文里有一个请求或通知事项
  • 签名块含职务和联系方式
  • 邮件正文里的引用回复链
  • 通篇没有任何账单字段

表单与登记

结构化采集——申请、理赔、入职登记。

  • 带标签的字段、值在固定位置
  • 复选框、下拉框和选项标记
  • 表单设计自带的编号章节标题
  • 手写区或签名框
  • 「第 N 页 / 共 M 页」页脚

其他——设计好的兜底

它是一个标签,不是失败:分流处理,不要硬判。

  • 不匹配上面任何标准的文档
  • 抽取输出混杂或为空
  • 所有标签的置信度都低于阈值
  • 新供应商带来的未知版式
  • 当作工作队列处理,而不是报错
实时交互体验 / 文档包拆分分类沙盒

文档包两阶段拆分与分类模拟器

选一个文档包并运行两阶段管道:逐页 Noul 判边界 → 拆分段 → Choice 判类型与置信度分流(纯前端模拟,不调用真实 API):

is_single_document + needs_ocr + doc_type
4 页
p1

发件人 ops@acme.co——「你好,本月账务文件见附件:3 月发票、刷卡收据、已签署的 MSA 续约。」

p2

INVOICE #2026-0341 · ACME Supplies Ltd. · 14 条明细 · 小计 $11,618.32 · 增值税 7.4% · 应付合计:$12,480.00 · 账期 30 天

p3

收据 — 已支付 · Visa ****4218 · $12,480.00 · 参考号 TXN-88213 · 2026-03-02 · 无待付

p4

主服务协议 · Acme Co. × Beta LLC · 期限 2026-04-01 – 2028-03-31 · 签署状态:已签署

source: 入站 / ops@acme.co
Jev 决策输出
~148ms / ≈$0.0001

点击「运行拆分 + 分类」,查看边界判定、分包结果与每份文档的类型置信度。

代码中的策略:is_single_document = true → 开启新分段 · needs_ocr 或 doc_type = other → 人工 · 置信度 ≥ 0.85 → 自动归档 · 0.60–0.85 → 复核队列 · < 0.60 → 人工复核

文档包分类对比:Jev 两阶段管道 vs 一条超长 LLM 提示词

METRIC
Jev
通用 LLM 长提示词拆分
分包准确性
逐页 Noul + 校准置信度——有争议的边界升级复核,而不是硬猜
对整个文档包一次性长提示词硬猜——错的拆分也显得很自信
类型标签可靠性
基于版本化 criteria 的类型化 Choice——0% schema 错误,无需解析
自由文本判定——2~8% 非法 JSON,需要解析重试循环
单包延迟
每页约 100–500ms、页间并行——9 页扫描件墙钟约 500ms 以内
2~6 秒逐 token 生成所有页的判定
批量成本
9 页文档包约 $0.0001——仅输入计费 $0.042/M,输出免费
每页重付一遍输入费用,外加每个判定和重试的输出 token
幻觉风险
零生成——模型无法编造页文本、金额或 schema 之外的标签
可能改写或捏造明细行;每个抽取金额都要另行校验
混排版式鲁棒性
页级独立——一页抽取坏了只影响一个判定,且会被 needs_ocr 标出
上下文稀释——9 页噪音淹没唯一关键的条款

生产代码:TypeScript 与 Python 双端文档包管道

typescript / 两阶段分包与分类
const JEV_ENDPOINT = "https://api.typesafe.ai/v1/jev/evaluate";

// 策略写在应用代码里——模型只负责报告事实。
const AUTO_FILE_CONFIDENCE = 0.85; // ≥ 0.85 → 自动归档
const REVIEW_MIN_CONFIDENCE = 0.6; // 0.60~0.85 → 复核,低于 → 人工

const QUESTIONS = {
  is_single_document: {
    type: "noul",
    instructions:
      "这一页是新文档的开始,还是上一页文档的延续?",
  },
  needs_ocr: {
    type: "noul",
    instructions:
      "这一页的文本是否缺失或乱到无法可靠判定,需要先跑 OCR?",
  },
  doc_type: {
    type: "choice",
    instructions: "把这份文档归入恰好一个类型",
    criteria: {
      invoice: "逐项计费:明细行、数量、总额、税、付款条款",
      contract: "有约束力的协议:签约方、条款、期限日期、签名栏",
      receipt: "支付完成凭证:已付标记、交易参考号、无待付金额",
      letter: "人工往来沟通:称呼、正文、签名块",
      other: "以上都不匹配且无把握",
    },
  },
} as const;

export type DocLane = "auto_file" | "review" | "human_review";

export async function evaluatePage(page: {
  text: string;
  page_index: number;
  source: string;
  has_ocr: boolean;
}) {
  const response = await fetch(JEV_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ state: page, questions: QUESTIONS }),
  });
  if (!response.ok) throw new Error("Jev evaluate failed");
  const data = await response.json();

  const { is_single_document, needs_ocr, doc_type } = data;
  const lane: DocLane =
    needs_ocr.answer || doc_type.answer === "other"
      ? "human_review"
      : doc_type.confidence >= AUTO_FILE_CONFIDENCE
        ? "auto_file"
        : doc_type.confidence >= REVIEW_MIN_CONFIDENCE
          ? "review"
          : "human_review";

  return {
    page_index: page.page_index,
    starts_document: is_single_document.answer,
    boundary_confidence: is_single_document.confidence,
    needs_ocr: needs_ocr.answer,
    doc_type: doc_type.answer,
    doc_type_confidence: doc_type.confidence,
    lane,
  };
}

// 边界与类型答案在同一次调用里返回:starts_document = true 的页
// 开启新分段,其余页面归入当前分段。
export function splitPacket(pages: Awaited<ReturnType<typeof evaluatePage>>[]) {
  const segments: (typeof pages)[] = [];
  for (const page of pages) {
    if (page.starts_document || segments.length === 0) segments.push([page]);
    else segments[segments.length - 1].push(page);
  }
  return segments;
}

// 页与页相互独立——生产环境并发扇出。9 页文档包墙钟约 500ms 以内,
// 仅输入计费,0% schema 错误。
const results = await Promise.all(packetPages.map(evaluatePage));
const segments = splitPacket(results);
const reviewQueue = results.filter((r) => r.lane !== "auto_file");
python / 入站扫描队列的批量分类
import requests

JEV_ENDPOINT = "https://api.typesafe.ai/v1/jev/evaluate"
AUTO_FILE_CONFIDENCE = 0.85   # ≥ 0.85 → 自动归档
REVIEW_MIN_CONFIDENCE = 0.60  # 0.60~0.85 → 复核队列,低于 → 人工

QUESTIONS = {
    "is_single_document": {
        "type": "noul",
        "instructions": "这一页是新文档的开始,还是上一页文档的延续?",
    },
    "needs_ocr": {
        "type": "noul",
        "instructions": "这一页的文本是否缺失或乱到无法可靠判定,需要先跑 OCR?",
    },
    "doc_type": {
        "type": "choice",
        "instructions": "把这份文档归入恰好一个类型",
        "criteria": {
            "invoice": "逐项计费:明细行、数量、总额、税、付款条款",
            "contract": "有约束力的协议:签约方、条款、期限日期、签名栏",
            "receipt": "支付完成凭证:已付标记、交易参考号、无待付金额",
            "letter": "人工往来沟通:称呼、正文、签名块",
            "other": "以上都不匹配且无把握",
        },
    },
}

def evaluate_page(page: dict) -> dict:
    resp = requests.post(
        JEV_ENDPOINT,
        json={"state": page, "questions": QUESTIONS},
        timeout=5,
    )
    resp.raise_for_status()
    data = resp.json()
    doc = data["doc_type"]

    if data["needs_ocr"]["answer"] or doc["answer"] == "other":
        lane = "human_review"
    elif doc["confidence"] >= AUTO_FILE_CONFIDENCE:
        lane = "auto_file"
    elif doc["confidence"] >= REVIEW_MIN_CONFIDENCE:
        lane = "review"
    else:
        lane = "human_review"

    return {
        "page_index": page["page_index"],
        "starts_document": data["is_single_document"]["answer"],
        "doc_type": doc["answer"],
        "confidence": doc["confidence"],
        "lane": lane,
    }

def classify_packet(pages: list[dict]) -> list[list[dict]]:
    # 页与页相互独立——生产环境并发扇出。
    results = [evaluate_page(p) for p in pages]
    segments: list[list[dict]] = []
    for page in results:
        if page["starts_document"] or not segments:
            segments.append([page])
        else:
            segments[-1].append(page)
    return segments

def scan_inbound_queue(packets: list[list[dict]]) -> dict:
    # 每页 ~100-500ms,仅输入计费 $0.042/M:一万包存量扫描
    # 不到一美元,完全不需要 GPU。
    lanes: dict[str, int] = {}
    for packet in packets:
        for segment in classify_packet(packet):
            lane = segment[0]["lane"]
            lanes[lane] = lanes.get(lane, 0) + 1
    return lanes

文档分类常见问题

文档分类和文本分类有什么区别?

文本分类给一段自足的文本打标签——一张工单、一条评论、一条消息。文档分类面对的是文档包:带 3 份附件的邮件、混入 8 页发票和 1 页合同的扫描批次、把不相关文档拼在一起的 PDF 导出。两阶段管道补上了文本分类从来不需要的一步——用页级 Noul 判定一份文档在哪里结束、下一份从哪里开始——然后才用类型化 Choice 给每个分段分类。单文本的通用场景见文本分类 API 指南。

Jev 能读扫描件、图片或手写体吗?

不能——把这一点说清楚,正是演示和生产管道的区别。Jev 的 state 是文本:模型里没有 OCR、没有版面分析、也没有视觉能力。扫描文档包在前游跑 OCR 和抽取,把产出的文本传进来。Jev 补的是 needs_ocr Noul:把文本缺失或乱到无法判定的页标出来,送进修复队列,而不是给出一个自信的错误标签。OCR 质量差会封顶所有下游分类器的准确率——类型化的和生成式的都一样。

分包到底是怎么拆的?

每一页都会得到一个 is_single_document Noul:「这一页是新文档的开始,还是上一页的延续?」答案为 true 的页(第一页永远为 true)开启一个新分段,其余页面归入当前分段。校准置信度比布尔值更重要:0.55 置信度的边界就是有争议的边界,而有争议的边界——和其他所有低置信度判定一样——应该落进复核队列,而不是悄悄把合同和它的发票拆散。

置信度阈值应该设多少?

从置信度门控降级链的三车道起步:≥ 0.85 自动归档,0.60–0.85 进复核队列,低于 0.60——加上所有「other」标签和所有 needs_ocr 页——升级人工。然后用标注样本推导自己的切分点:把预测置信度和运营判定在真实文档包上画成曲线,把每个切分点放在「归错档也容易捞回来」的位置。强制生效前先影子模式,之后每季度重验——新供应商意味着新版式,意味着分布漂移。

DocJev 是什么?是官方的吗?

DocJev 是 LlamaIndex 创始人 Jerry Liu 开源的一个库,用于分类和拆分复杂文档包——和本页实装的是同一个两阶段模式。它入选了 madewithjev 展示区的 Documents & OCR 栏目,作者自报单包约 139ms、自建基准 40/40 正确(作者自报数据,非独立审计)。DocJev 是第三方生态项目,不是 TypeSafe AI 官方产品——madewithjev 也不是。

大批量分类要花多少钱?

只按输入 token 计费($0.042/M),输出免费。一个 9 页文档包按每页约 300 token 算约 2,700 输入 token ≈ $0.0001,一万包的存量扫描不到一美元,完全不需要 GPU。生态里的公开数据点:1kpapers 把 1,018 篇 AI 研究论文全部分类只花了 $0.08,中位延迟约 256ms。生成式管道要付相当的输入账单,外加每个判定的输出 token——每次重试还要再付一遍。

继续扩展文档管道