Skip to content
🎯 场景实战第 3 / 16 节

深层级分类:把内容分到类目最深处 ​

💡 一句话总结:用 Jev 层级分类穿透深层类目树 —— 每层一道 Choice 拿全概率分布、K=3 并行 beam search 逐层下走、几何平均分排序截断。

你的类目表可能比你想的深得多。官方 Hierarchical Classification cookbook 拿四棵真实类目树做实验:CPC 专利分类 254,274 个节点、Shopify 零售商品类目 12,378 个、MeSH 生物医学主题 65,373 个、还有一套源码目录树 125 个节点。文档要分到叶子才算分完——比如一条「鸟类栖木」的专利摘要,最终要落到 A01K31/12 Perches for poultry or birds, e.g. roosts 这个具体类目。

一道 Choice 最多 255 个选项,根本装不下 25 万个节点。官方的做法:把类目树当成菜单,一层一层往下问——每个节点只问「哪个直接子类最匹配」,答案的概率分布就是通往下一层的边。这是官方文档点名的真二次请求场景之一:代码必须拿到上一层的答案,才知道下一层该给哪些选项(primitives.md 原话:uses each Choice answer to decide which options the next request offers)。

一层层问还有个隐藏陷阱:浅层判断常常是模糊的(「这条商品描述算宠物用品还是家具?」),每层只押一个答案,错一步就再也回不来。官方的解法是 beam search:保留 K=3 条候选路径,每轮并行展开,用深层的证据修复浅层的歧义。官方实测:greedy 命中 2/4,beam K=3 命中 4/4。本课约 20 分钟带你完整走一遍(官方难度:Intermediate)。

学完你能做什么 ​

  • 把任何深层级类目(电商类目、邮件文件夹、工单体系、源码目录)变成嵌套字典,每层一道 Choice 往下问
  • 写出 beam search 完整实现:K=3 候选路径、几何平均分排序、ThreadPoolExecutor 并行展开
  • 读懂 separation 分离度指标,知道什么时候该转人工而不是硬分
  • 讲透官方 4 个数据集的实测结论:greedy 2/4 → beam 4/4,包括 CPC 和 Shopify 上 beam 如何修复浅层歧义

为什么用 Jev,而不是普通 LLM 分层分类? ​

普通 LLM 也能一层层问「该走哪个子类」,但每层你都要解析它吐的文本,而且它给不出量化的把握。Jev 每层的答案自带概率分布:

能力普通 LLM 逐层分类Jev 层级分类
每层选项模型自由发挥,类目名天天漂答案永远是当前节点的直接子类之一,无解析
把握程度要追问「你确定吗」,回答也是文本每条边自带概率,几何平均分直接算
浅层歧义押一个,错了就往下硬编beam search 保留 K=3 条路,深层证据回头修复
候选路径并行逐层逐路径发请求,延迟叠 K 倍beam 里每条路径各发一道题,同一轮并行发出
观测调试中间过程是黑盒每个节点、每条边的概率都存下来,错在哪一层一目了然

最后一个不是小事。官方 cookbook 专门强调:分解成层级本身就送你可观测性(哪个节点的误分类最多、每条边被走过多少次)和可测试性(类目表更新后,单测直接量化对分类质量的影响)。

你现在的困境 ​

  • 「商品库 300 万条,类目树 6 层 12,000 个节点,AI 商品分类浅层还行,越深越没人说得准,人工一条条翻」
  • 「AI 邮件分类做了三层文件夹,模型每次都硬选一个文件夹,错一层后面全错,还不知道错在哪层」
  • 「想让 LLM 输出 JSON 分类结果,格式崩一次管道就断一天;想加『不确定就转人工』,又不知道怎么量化不确定」

什么时候用这一招 ​

当你需要:

  • 把文档分进几十层深、几千到几十万节点的类目树,而且要落到叶子
  • 每层的判断有歧义时保留备选——深层的证据说了算
  • 每一步都留下概率轨迹:错在哪一层、哪条边最模糊,都能查

而且不想:

  • 一次塞 255 个以上的选项(Choice 硬上限)
  • 每层解析 LLM 吐出来的自由文本
  • 类目表一更新就要重调 prompt 重跑全量

🎒 开始前的准备 ​

  • 完成了 Choice 选择题:会写 criteria 选项 map,读得懂 choice 和 probabilities
  • 跑通过安装与第一个请求:.venv 可用,TYPESAFE_API_KEY 在环境变量里
  • (更顺)读过并行提问与投机扇出:本页的 beam 并行展开就是同一条「问题并行评估」的能力
  • 一杯水 ☕——本页代码分六步攒成一个文件:一棵树、两种搜索、一组指标,第 6 步才第一次看到输出

跟我做 ​

新建 hierarchical_classification.py,把下面每一步的代码块按顺序贴进同一个文件。本页不下载官方数据集——官方加载代码很长、下载体积也不小;我们直接用官方已验证的结论和数字,代码聚焦在算法本身,换成你自己的类目树就能跑。

第 1 步:把扁平类目树改成嵌套字典 ​

为什么:层级分类的第一件事不是发请求,是把你的类目表变成 Tree = dict[str, Tree]——每个节点只列直接子类。下一层长什么样,要等上一层的答案才知道,这正是「真二次请求」的定义。

先贴入骨架:

python
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

from typesafe_sdk import Choice, RetryPolicy, TypeSafeClient

Tree = dict[str, "Tree"]  # 每个节点 = {直接子类名: 它自己的子树};叶子 = {}

# 换成你的类目:邮箱文件夹、电商类目、工单体系都行
TREE: Tree = {
    "退货/物流": {
        "退货流程": {"退货地址": {}, "退款进度": {}},
        "物流查询": {"快递单号": {}, "配送异常": {}},
    },
    "产品咨询": {
        "尺码规格": {},
        "使用方法": {},
    },
}

DOCUMENT = "客户消息:我上周退的裙子,退款怎么还没到账?"

Tree 里每个键就是当前节点的一道题的选项;空字典 {} 就是叶子——分到这里,分类结束。

你应该看到:文件还跑不出任何东西——TREE 和 DOCUMENT 只是数据,发请求的代码第 2 步才贴。

  • 我的 TREE 里每个节点只列直接子类,没有跨层跳跃

第 2 步:写 child_question:每层一道 Choice 问「哪个直接子类最匹配」 ​

为什么:官方的做法是把每个节点的子类列表组装成一道 Choice:instructions 固定一句话,criteria 是子类映射。keys 用 c0、c1 这种短键名,答案回来再映射回原始 label——类目名常常很长很怪(Cat Window Beds & Perches),短键名让概率分布干净利落。

接着贴入客户端和问题构造:

python
MODEL, BEAM_WIDTH, MAX_DEPTH, EPSILON = "jev-1.12", 3, 12, 1e-9

client = TypeSafeClient(
    api_key=os.environ["TYPESAFE_API_KEY"],
    retry=RetryPolicy(max_retries=5, backoff_initial=1.0, backoff_max=20.0),
)


def child_question(labels: tuple[str, ...]) -> tuple[Choice, dict[str, str]]:
    """Build the direct-child Choice and its reversible option mapping."""
    keys = {f"c{i}": label for i, label in enumerate(labels)}
    question = Choice(
        instructions="Which direct child category best matches this document?",
        criteria=keys,
    )
    return question, keys


def subtree(tree: Tree, path: tuple[str, ...]) -> Tree:
    """Return the direct-child menu below ``path``."""
    value = tree
    for label in path:
        value = value[label]
    return value

MODEL 用官方 cookbook 采样用的 jev-1.12;换成 jev-latest 也行,概率数字会和官方录制的略有出入。MAX_DEPTH=12 是安全阀——官方 MeSH 的 Crohn Disease 路径有 6 层,够用且防爆栈。

你应该看到:还是没有输出——客户端和 child_question() 只是定义,还没有任何代码调用它们。

  • 我能说出 criteria 的 keys 为什么用 c0/c1 而不是原始类目名

第 3 步:实现 greedy:每层只押概率最高的一个子节点 ​

为什么:贪心是最直觉的走法,也是官方对照组。先跑通它,你就亲眼看到层级分类的死穴:一次早错,无法挽回。

接着贴入请求封装和贪心:

python
def choose(state: str, labels: tuple[str, ...]) -> dict[str, float]:
    """Ask one atomic direct-child question and return its distribution."""
    if len(labels) == 1:  # 只有一个子类:不是决策,不发请求
        return {labels[0]: 1.0}
    question, keys = child_question(labels)
    response = client.system_one(state=state, questions={"child": question}, model=MODEL)
    probabilities = response.answers["child"].probabilities
    return {label: probabilities[key] for key, label in keys.items()}


def greedy_search(tree: Tree, document: str) -> dict:
    """Follow only the locally highest-probability child."""
    path, probability_product, decision_count = (), 1.0, 0
    for _ in range(MAX_DEPTH):
        labels = tuple(subtree(tree, path))
        if not labels:
            break
        probabilities = choose(document, labels)
        label = max(probabilities, key=probabilities.get)
        if len(probabilities) > 1:
            probability_product *= max(probabilities[label], EPSILON)
            decision_count += 1
        path += (label,)
    score = probability_product ** (1 / decision_count) if decision_count else 1.0
    return {"path": path, "score": score}

两个官方细节。单子节点不是决策:choose() 在只有一个子类时直接返回 1.0,不发请求、不进 decision_count——概率连乘除以决策数,才不会被这种「假决策」稀释。EPSILON=1e-9:概率为 0 的边在连乘里会把整条路径打成 0,几何平均分就废了;官方用 1e-9 兜底。

运行(设好 TYPESAFE_API_KEY):

bash
python hierarchical_classification.py

你应该看到:还没有输出——贪心函数刚定义好。但你可以先口头走一遍:退货/物流 vs 产品咨询 一问,退货流程 vs 物流查询 一问,退货地址 vs 退款进度 一问,三次请求分到叶子。

  • 我能说出为什么单子节点不算一次决策

第 4 步:实现 beam search:保留最好的 3 条路径逐层并行展开 ​

为什么:这是本课的核心。贪心在浅层错了就全错;beam search 保留 K 条候选,每轮把所有可展开路径并行发出去问,用深层的证据给浅层的歧义第二次机会。

接着贴入:

python
def extend_candidate(candidate: dict, label: str, probabilities: dict[str, float]) -> dict:
    """Append one edge and recompute its geometric-mean path score."""
    is_decision = len(probabilities) > 1
    probability_product = candidate["probability_product"] * (
        max(probabilities[label], EPSILON) if is_decision else 1.0
    )
    decision_count = candidate["decision_count"] + is_decision
    return {
        "path": candidate["path"] + (label,),
        "probability_product": probability_product,
        "decision_count": decision_count,
        "score": probability_product ** (1 / decision_count) if decision_count else 1.0,
    }


def beam_search(tree: Tree, document: str) -> dict:
    """Parallel width-three beam search using geometric-mean probability."""
    beam = [{"path": (), "probability_product": 1.0, "decision_count": 0, "score": 1.0}]
    for _ in range(MAX_DEPTH):
        expandable = [c for c in beam if subtree(tree, c["path"])]
        finished = [c for c in beam if not subtree(tree, c["path"])]
        if not expandable:
            break
        with ThreadPoolExecutor(max_workers=BEAM_WIDTH) as executor:
            distributions = list(
                executor.map(
                    lambda c: choose(document, tuple(subtree(tree, c["path"]))),
                    expandable,
                )
            )
        expanded = []
        for candidate, probabilities in zip(expandable, distributions, strict=True):
            for label in probabilities:
                expanded.append(extend_candidate(candidate, label, probabilities))
        beam = sorted(finished + expanded, key=lambda c: c["score"], reverse=True)[:BEAM_WIDTH]
    beam = sorted(beam, key=lambda c: c["score"], reverse=True)
    return {"beam": beam}

读法按官方公式来:

  • path_score = product(edge_probabilities) ** (1 / decisions)——几何平均。为什么不是连乘:连乘会惩罚深路径,一个 10 层全 0.9 的路径(0.35)会输给 3 层全 0.9 的路径(0.73);几何平均把深度归一,浅叶深叶公平比
  • separation = top_path_score / second_path_score——第一名和第二名的比值,不用于剪枝,是给你看歧义程度的
  • 每轮 beam 按分数排序截断到 K=3;已到叶子的路径带着分数留在 finished 里继续参与最终排序

💡 这就是 Jev 的独有价值

beam search 本身是老算法,机器翻译解码用了几十年。Jev 给它的是燃料:每一步的完整概率分布。普通 LLM 一层只给你一个「它觉得对的」答案,beam 的 K 条路径无从打分;Jev 每条边都是量化概率,几何平均、排序、剪枝全是纯代码。官方还有一句重要的:beam 里的每条路径作为并行问题一起发,多探索几条路几乎不增加墙钟延迟。

你应该看到:依然没有输出——beam_search() 定义完还没人调用,第 6 步才第一次跑它。

  • 我能说出为什么用几何平均而不是连乘给路径打分

第 5 步:跑四棵官方类目树,对比 Jev 层级分类的两种走法 ​

为什么:你的 TREE 太小,看不出差距。官方拿四棵真实类目树各跑一遍,已知答案对答案。

你应该看到(官方录制的对照结果):

类目树版本节点数文档示例Greedy 结果Beam K=3 结果
CPC 专利2026.05254,274鸟类栖木专利摘要❌ E99Z99/00(兜底类)✅ A01K31/12
Shopify 商品2026-0212,378猫用飘窗床商品描述❌ Pet Chairs✅ Cat Window Beds & Perches
MeSH 生物医学202665,373克罗恩病临床摘要✅ Crohn Disease✅ Crohn Disease
CookSafe 源码快照 2026-08-06125找实验检索模块✅ retrievers.py✅ retrievers.py

两个错例都是同一个死法。看 Shopify 那条「猫用飘窗床」的商品描述怎么死的:

  • 第 2 层,贪心在 Pet Supplies 下选了 Pet Beds(概率 0.53),丢掉了 Cat Supplies(0.45)——就是这步 8 个百分点的差距,把整条路径带进了狗窝猫窝不分的世界,最后落到 Pet Chairs
  • beam 保留的另一条路径走 Cat Supplies 0.45 → Cat Furniture 0.98 → Cat Window Beds & Perches 0.92——第 3、4 层连续高概率,几何平均分反超,赢了
  • CPC 同款:贪心第 1 层就被 E 固定建筑 0.62 带走(正确答案 A 人类必需 只有 0.34),后面进 E99Z 兜底类;beam 留着的 A 路径从第 2 层起 0.95、0.99、0.71、0.99 一路高歌

MeSH 和 CookSafe 两种走法都对——beam 的价值不是必胜,是修复浅层歧义;浅层清晰时它只是多花几道题的 token。

  • 我能复述 Shopify 例子中贪心错在哪一层、beam 怎么救回来的

第 6 步:读懂 top/second 分离度,决定要不要转人工 ​

为什么:拿到最终分类,还要回答「这个分类能信吗」。beam 的副产品是 separation:第一名和第二名路径分数的比值。

python
result = beam_search(TREE, DOCUMENT)
beam = result["beam"]
if len(beam) > 1:
    separation = beam[0]["score"] / max(beam[1]["score"], EPSILON)
    print(f"top/second = {separation:.2f}x")
print("leaf:", beam[0]["path"][-1] if beam[0]["path"] else "no leaf")

你应该看到:top/second 比值和最终叶子各一行(换成你自己的 TREE,数值自然不同)。官方四棵树的录制数字给你校准感:CPC 1.04×、Shopify 1.17×、MeSH 1.81×、CookSafe 2.20×。接近 1× 就是歧义——CPC 那次虽然答对了,第一名和第二名几乎贴着,说明文档在两个深类目之间真模糊。官方对这个指标的定性:near 1× is ambiguous;大比值意味着清晰的分离。

它不用于剪枝——剪枝只看每条路径自己的几何平均分。separation 是给人看的:贴着 1× 的分类别直接写库,转人工或用自一致性再投一次。

  • 我的代码输出了 top/second 比值;我知道 1.04× 和 2.20× 的差别在哪

📋 魔法 Prompt ​

本页的「Prompt」是给 API 的问题模板——抄走、替换【】占位符就能用到你自己的类目树。三件套:逐层提问、打分剪枝、单子节点。

📝 直接子类提问(Choice 版) ​

预期效果:把任何一个类目节点的「往哪走」变成一道 Choice,答案自带全分布,K 条路径都能拿边概率。

python
from typesafe_sdk import Choice

# 必填项:【】里换成你当前节点的直接子类
question, keys = child_question(("【子类A】", "【子类B】", "【子类C】"))
# child_question 的核心是:
# Choice(
#     instructions="Which direct child category best matches this document?",
#     criteria={"c0": "【子类A】", "c1": "【子类B】", "c2": "【子类C】"},
# )

# 输出规范:answer.choice 是最高概率子类的键;answer.probabilities 是全部子类的分布
# 约束条件:
# ✅ 只列直接子类,不要跨层塞孙类
# ✅ 键名用 c0/c1 短名,label 放进 criteria 的值
# ❌ 不要塞超过 255 个选项——Choice 硬上限

🔧 路径打分与剪枝(几何平均版) ​

预期效果:给每条候选路径一个深度归一的分数,按分排序截断到 K 条——浅叶深叶公平比。

python
# 必填项:【】里换成你的 beam 宽度和深度安全阀
BEAM_WIDTH = 【3 起步,官方示例值;类目树深且浅层模糊可加宽】
MAX_DEPTH = 【12 起步,盖过你最深的路径;防爆栈】
EPSILON = 1e-9

# score = product(edge_probabilities) ** (1 / decision_count)
# 约束条件:
# ✅ 单子节点(len(probabilities)==1)不进连乘、不计决策数
# ✅ 每轮把已到叶子的路径留在 finished 里参与最终排序
# ❌ 不要用连乘打分——深路径被系统性惩罚

✅ 深树数值稳定(对数空间版) ​

预期效果:类目树超过 10 层时,连乘浮点误差会把分数算糊;换对数空间的等价公式。

python
# 官方原文的替代方案:exp(mean(log(probs))) 等价于 product ** (1/decisions)
import math

def log_space_score(probs: list[float]) -> float:
    return math.exp(sum(math.log(max(p, EPSILON)) for p in probs) / len(probs))

# 约束条件:
# ✅ 树深 > 10 层时换这个公式,避免精度损失
# ❌ 不要在浅树上也换——多一层换算,没必要

检查点 ✅ ​

  • hierarchical_classification.py 能跑:beam 对我的 TREE 返回 K=3 条排序路径,打印 top/second 比值和最终叶子
  • 我能说出 beam 的三个部件:几何平均分、每轮截断到 K、finished 路径保留
  • 我能复述官方 4 数据集结论:greedy 2/4、beam 4/4,Shopify 死在第 2 层 0.53 vs 0.45
  • 我知道单子节点不算决策(返回 1.0、不进 decision_count)
  • 我知道 separation 接近 1× 该转人工,不用于剪枝

踩坑提醒 ​

❌ 一道题塞下整棵类目树 ​

Choice 硬上限 255 个选项(choice.md 原话:A Choice question accepts up to 255 options)。25 万节点的树不是「多分几道题」的事,而是层级结构本身就是答案:每层一题,题数 = 深度,不 = 节点数。官方 MeSH 的 Crohn Disease 路径 6 层,beam K=3 总请求也就十几个。

❌ 用连乘而不是几何平均给路径打分 ​

连乘天然惩罚深路径:全 0.9 的 10 层路径(0.35)输给全 0.9 的 3 层路径(0.73),明明每步一样稳。几何平均 product ** (1/decisions) 把深度归一——官方原话:The probability is length-normalized so that shallow and deep leaves are compared fairly。深树再加保险:exp(mean(log(probs))) 避免精度损失。

❌ 每层串行发请求,不做 beam 内并行 ​

beam 的 K 条路径互相独立,executor.map 一次全发出去——这就是 Jev 并行提问那条「问题并行评估」的能力。逐路径串行等答案,K=3 的墙钟时间直接乘 3,beam 的扩展性就没了。

❌ 把 MeSH 这类 DAG 直接当树走 ​

MeSH 一个描述符可以有多个父节点(DAG)。官方的做法:把官方 tree number 路径展开成树——一个描述符有多条 tree number,就在树里占多个位置。你的类目如果是 DAG(一个商品挂在多个类目下),先想清楚你要的是哪棵展开;直接走会在分叉处漏路径。

❌ 中文文档直接上线 ​

官方明说英语是主要训练语言,CJK 能用但准确度较低。中文商品描述、中文邮件先拿真实数据测一轮,重点看每层概率的分离程度——浅层都贴着 0.5 的树,beam 再宽也救不回来。详见模型与价格。

进阶技巧(可选) ​

换打分指标。 官方给的替代指标:min(top_prob/second_top_prob)——优化「每个节点都分得很清晰」的路径,而不是「平均清晰」。浅层节点多、单个节点歧义代价大的场景值得一试。

缓存重放。 官方用 cooksafe 库的 JsonCache 装饰 choose()——答案按「state + 问题」的键存进 JSON,重跑零请求零花费。类目表天天调、文档天天加的场景,缓存能省大钱(用法见直播聊天过滤第 1 步)。

看官方图。 cookbook 页面上有四棵树的完整遍历 SVG(官方链接:CPC、Shopify、MeSH、源码)——橙色贪心路线、绿色 beam 赢家、紫色保留路径、虚线剪枝边,本页第 5 步的所有数字都能在图上对上。

类目表更新后跑回归。 官方强调的可测试性:把已知答案的文档存成测试集,类目表每次改版后重跑 beam,命中率和 separation 的变化一目了然——这比「改完 prompt 求好运」踏实得多。

本课小结 ​

✅ Jev 层级分类流水线:类目树 → 嵌套字典 → 每层一道 Choice → K=3 并行 beam search → 几何平均分排序 → separation 判歧义。

✅ 官方实测:CPC 254,274 节点、Shopify 12,378、MeSH 65,373、CookSafe 125——greedy 2/4 命中,beam K=3 4/4;CPC 和 Shopify 都是浅层歧义(0.53 vs 0.45、0.62 vs 0.34)被 beam 的深层证据救回。

✅ 三个关键设计:单子节点不算决策(1.0 且不进 decision_count)、几何平均深度归一(连乘惩罚深路径)、EPSILON=1e-9 兜底零概率边。

✅ 这是官方点名的真二次请求场景:代码必须拿上一层的答案才能构造下一层的选项——和 skill_suggestion、autoformat 并列三个真例。

关键配置字段回顾:

字段本页的值说明
BEAM_WIDTH3官方示例值;浅层歧义重可加宽
MAX_DEPTH12安全阀;MeSH 最深 6 层
EPSILON1e-9零概率边兜底,保护几何平均
MODEL"jev-1.12"官方采样版本;换 jev-latest 数字有出入
RetryPolicymax_retries=5, backoff 1.0→20.0官方示例配置
每层问题数1beam 的多条路径在同一轮并行发出

相关教程:

  • 75 类行业分类——类目只有两级、75 个选项一次装得下时,用 confidence 门控替代层级遍历
  • Choice 选择题——本页的地基:criteria 选项 map、probabilities 分布、255 上限
  • 自一致性——separation 贴着 1× 时别硬分,多投几次看一致率

下一课预告 ​

beam 保住了深层准确率,但有些任务浅层就两极分化——答案没把握时,与其硬选不如自动上报一级大类。下一课:75 类行业分类:置信度不够就上报大类。

你会学到:

  • 一道 75 选项的 Choice 分进 SEC 全部行业组,答案自带 confidence
  • confidence ≥ 0.9 报行业组、不够就查表上报所属大类——零第二次调用
  • 官方 60 份年报实测:门控把可用率从 39/60 提到 48/60
搞懂AI关于我们怎么写隐私说明

独立的第三方教程,与文中提到的任何产品及其公司没有隶属或合作关系;产品名称和商标归各自所有者。价格和政策以官方页面为准。