用小模型生成虚假类目再用嵌入向量映射回真实分类树,可大幅降低长类目列表带来的token消耗和延迟,适合电商等400-800类的场景。
将自由文本查询分类到固定的产品分类体系中,是生产环境中最为常见的 LLM 工作负载之一,也是最容易被忽视的高昂成本之一。一个典型的电商目录通常包含 400 到 800 个合法类目,每个类目都以完全限定路径的形式拼写出来,例如 Furniture / Living Room Furniture / Coffee Tables & End Tables / Coffee Tables。每次搜索查询需要标签时,整个词表都必须随提示词一起传递:可以是一个巨大的 Pydantic Literal,可以是一个 JSON schema,也可以是几百行 enum 文本。Token 不是免费的,延迟也不是。提示词越来越大,你原本想用的小模型开始出问题,本来应该只花几分钱的任务现在需要动用最大的模型才能保证输出有效。
有一种更廉价的方案,大多数团队从未考虑过。与其强制模型从真实分类体系中选择,不如让一个小型、廉价的模型发明一些听起来合理但实际上是虚构的类目,然后再通过 embedding 查找将这些幻觉映射回真实词表。这听起来很反直觉,但效果出奇地好。本文将逐步介绍这个思路、实现方案、失败模式,以及在信任该方案之前应该收集的测量数据。
传统方案是结构化输出。你把合法词表定义为一个类型,交给提供商,然后请求一次约束解码:
from typing import Literal
from pydantic import BaseModel, Field
FullyQualifiedClassifications = Literal[
"Furniture / Bedroom Furniture / Beds & Headboards / Beds",
"Furniture / Living Room Furniture / Chairs & Seating / Accent Chairs",
"Rugs / Area Rugs",
# ... times 500
]
class QueryClassification(BaseModel):
"""Structured representation of a search query."""
classifications: list[FullyQualifiedClassifications] = Field(
description="Possible classifications for the product."
)
约束解码保证了答案必然来自合法集合,这是一个很强的特性。但它有一个隐藏成本,且这个成本随目录规模增长而增加。Schema 必须随每个请求发送,所以每次调用都要支付完整词表的输入 token。分类是一种高并发、低单次价值的 workload,恰恰是在这里浪费 token 最不明智的地方。
第二个问题是,这个保证完全取决于模型遵守 schema 的意愿。小模型面对 500 元素的 Literal 时会感到吃力;它们要么将其截断,要么开始幻觉出从未在列表中的类目——这使得整个设计变得毫无意义。我观察过遇到这个瓶颈的团队,他们的通常反应是升级模型,而整个流程的单次成本也随之水涨船高。
关键认识是:模型并不需要知道分类体系才能判断查询说的是什么东西。"brown coffee table" 这样的查询显然是一件客厅家具。难点不在于理解查询,而在于将这种理解映射到一棵有数百个叶节点的树上的具体节点。
所以,不要发送这棵树了。发送一个分类形态的示例,然后让模型去发明新的类目:
hallucination_prompt = f"""
Your task is to create novel, never seen before furniture, home goods,
or hardware classifications that best fit a search query.
Product classifications look like:
Furniture / Living Room Furniture / Coffee Tables & End Tables / Coffee Tables
Décor & Pillows / Decorative Pillows & Blankets / Throw Pillows
Furniture / Bedroom Furniture / Dressers & Chests
Kitchen & Tabletop / Kitchen Organization / Food Storage & Canisters
Baby & Kids / Toddler & Kids Bedroom Furniture / Kids Beds
Here is the query to generate classifications for:
brown coffee table
"""
现在的提示词只需几百 token,而不是原来的几千。模型没有合法词表可以违反,所以它会愉快地生成一个虚构路径,比如 Furniture / Living Room / Tables / Coffee。这个输出本身作为标签是毫无用处的,但它却是对查询含义的精确描述——而且用的是与真实分类体系相同的风格。
现在问题变成了:给定一个虚构路径,如何找到它指向的真实类目。Embedding 将这个问题从字符串匹配变成了最近邻搜索,这一点至关重要,因为虚构路径和真实路径几乎不共享任何字面字符。
整个设置足够小,可以常驻内存。在启动时用一个小型的句子模型对所有真实类目路径做一次编码,将向量存在 NumPy 数组或一个微型向量库中,然后在请求时对虚构路径做编码。标签就是与虚构向量点积最高的真实类目:
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
real_paths = [...] # the full taxonomy, loaded once
real_vectors = model.encode(real_paths, normalize_embeddings=True)
def resolve(hallucinated_path: str) -> str:
v = model.encode([hallucinated_path], normalize_embeddings=True)[0]
idx = int(np.argmax(real_vectors @ v))
return real_paths[idx]
对于几百个类目的目录,这不过是一次矩阵-向量乘法,即使在笔记本 CPU 上也远低于一毫秒。流程中昂贵的部分现在是那个小 LLM 调用,而这正是关键点:你已经把成本从"带巨大 schema 的大模型"转移到了"短提示词的小模型 + 基本零成本的本地查找"。
这个方案听起来应该会失败,所以值得精确地解释它为什么不失败。小模型不擅长在大型词表上做约束生成:随着约束集合增大,它们的注意力会衰退,采样也会漂移到无效输出。但它们擅长开放式改写,也擅长按照示例的风格写作。为 "brown coffee table" 发明一个合理的类目路径是一项风格匹配任务,而非约束满足任务。这正是即便是 1B 参数的模型也能可靠完成的流畅生成任务。
与此同时,Embedding 模型才是真正掌握分类体系的组件。句子 Embedding 将虚构路径和真实路径都映射到一个空间,在那里,语义而非表面形式决定了距离。Furniture / Living Room / Tables / Coffee 和 Furniture / Living Room Furniture / Coffee Tables & End Tables / Coffee Tables 几乎不共享任何 token,但它们在空间中位置接近,因为 Embedding 空间的训练数据就是 paraphrase。每个组件做自己擅长的部分,不需要在对方的专长上表现强劲。
这个方案不是结构化输出的通用替代品,它是一种权衡。在决定采用之前,你应该测量三类失败。
第一类是词表漂移。小模型在发明类目时,有时会产生语义上过于宽泛的路径,例如 Furniture / Tables,它会映射到 embedding 空间中最接近的真实节点。如果你的分类体系中有多个桌子相关的类目,最近邻可能是错的,即使查询本身很具体。这是主要的失败模式,其频率是你分类体系的属性,而非模型的属性。
第二类是解析歧义。有些查询天生就信息不足:"black stand" 可能是手机支架、显示器支架,也可能是植物架。虚构路径会因为模型无法获知而自信地给出错误答案。这不是该方案的 bug;结构化输出在同样情况下也会失败,只是失败方式是返回一个自信满满但错误、却看起来符合 schema 的标签。
第三类是分类体系变动。如果你的目录经常变化,Embedding 索引必须重建,新添加的类目在此之前是不可见的。重建几百个 Embedding 只需几秒,所以这是一个运维细节而非阻碍,但它需要成为部署流程的一部分。
当词表庞大、查询简短、且你希望分类步骤足够便宜以至于可以对每个请求都运行而不用考虑账单时,这个方案就能大放异彩。当分类体系很小时,它并不适用;当标签必须是精确字符串、没有任何容忍度时,它也不适用;或者当你无法容忍任何比例的分类错误、偏好约束解码的确定性失败时,它也不适用。
实际上,你希望在解析步骤上设置一个置信度下限,而不仅仅是一个 argmax。虚构向量与最近真实向量之间的点积告诉你系统有多确定。一个在 0.45 余弦相似度上解析的路径是一个猜测;一个在 0.85 上解析的是一个确定答案。简单的修复方案是设置一个阈值,配合两个降级层次:
def classify(query: str, threshold: float = 0.6) -> tuple[str, str]:
fake = invent_categories(query) # small LLM, short prompt
real, score = resolve(fake) # embedding lookup
if score >= threshold:
return real, "auto"
return run_structured_classification(query), "fallback"
在阈值以下,你可以升级到更大模型上的结构化输出路径,或者路由到人工审核队列。由于廉价路径处理了大部分流量,降级路径只在模糊部分触发,即使最坏情况下使用了昂贵的 machinery,你的平均成本仍然很低。另一个值得添加的防护栏是一份已知的泛化叶节点短名单,这些节点会吞噬不相关的查询,这样一个泛化的虚构路径可以在被解析到一个无用桶之前就被拒绝。
对这个方案的常见反对意见是"我怎么知道它是准确的?"不依赖手工标注的测试集也能取得出乎意料的进展。从日志中采样几百条真实搜索查询,对同一条查询同时运行廉价路径和结构化输出路径,然后对比标签。不一致并不意味着廉价路径是错的,但每一个不一致都是值得细看的案例。在实践中,不一致会聚成两堆:一堆是廉价路径在语义上正确、而 schema 路径被迫进入一个错误但合法的叶节点;另一堆是廉价路径解析到了一个泛化的邻居。第一堆是这个方案在起作用的证据;第二堆则用来调整阈值。
跟踪解析分数分布作为指标。如果几周内平均分数下降,说明模型厂商做了变动或者你的分类体系发生了漂移;无论哪种情况,你都希望在分类错误率变动之前就知道。只需三个数字的小仪表盘——平均分数、升级率、与结构化路径的不一致率——就足以让你对在生产环境中运行这个方案充满信心。
完整流程足够短,可以放在脑中:一个微型模型从一个几百 token 的提示词中发明一条类目路径,一个 Embedding 查找在不到一毫秒内将该路径解析到真实叶节点,一个相似度阈值决定答案是直接返回还是升级处理。分类体系从不随请求发送,廉价模型保持廉价,昂贵的 machinery 只见到真正模糊的情况。
Hypothetical classification 是那种看起来像 hack、直到你实际测量了才会意识到其价值的想法之一。词表是分类过程中昂贵的部分,而 Embedding 模型让你可以只做一次、本地携带,而不是每次请求都发送。如果你的分类 workload 目前正在一个大模型上支付分类体系税,值得花一个周末做实验:用幻觉提示词替换 schema,添加查找逻辑,然后观察不一致率。数据会很快告诉你这个反直觉的想法是否正确。
Originally published on Dispatch.