← 返回
AI技术

我用LLM描述了1,245张表格,检索效果变差

✍️ zhirenhun 📅 2026/9/13 👁 70 阅读 ⏱ 18 分钟
我用LLM描述了1,245张表格,检索效果变差

编目步骤本应是易得的胜利。你有一个模式,其
表被称为 ecm_template_linkv_pmpm,你的用户用英文提问
,而这两种词汇之间的差距正是导致检索
失误的原因。于是你对每张表使用一个模型,得到描述每张表的一句话
,将这些句子与表名一起建立索引,现在语料库也能说
英文。

我对一个真实的 1,245 对象模式做了这件事。召回率 下降

相差甚远。名为 contacts 的表曾在排名第3位,针对
"显示 xmagnet 的联系人" 问题在编目前排名第3位。之后
编目后它降至第40名之后 — — 超出了我会放入提示中的任何内容
提示。这些描述很好。我读过了。它们准确,
具体,并且它们让系统变得更糟。

这篇文章描述了实际发生的情况,显而易见的修复方法不起作用,
以及有效的方法。这与 text-to-SQL 无关。如果你
在索引前丰富文档 — — 摘要、生成的标题,
假设性问题、关键词扩展,以及任何其他内容 — — 同样的机制
可能会咬到你,而且它不会自己宣布。

机制,一句话说明

你生成的每个
描述都使用与其他描述相同的词汇,因此丰富会恰好
提高你用户输入词汇的文档频率。

BM25 通过逆文档频率对术语进行评分:

idf(t) = log(1 + (N - n_t + 0.5) / (n_t + 0.5))

N 是语料库的文档总数,n_t 是包含该词的文档数。
一个只出现在少数文档中的词才有信息量,得分就高;一个出现在大多数
文档里的词一文不值,得分趋近于零。这就是 IDF 的全部要义,而且通常
也是个靠谱的直觉。

现在想一想,你让模型描述一个 CRM schema 里的表时,它会写出些什么。
它写的是联系人。描述 contacts 时它写联系人,描述 contact_lists
campaign_recipientsemail_eventstenantsusers
以及记录上述任意表变更的审计表时,它还是写联系人——因为在 CRM 里,几乎一切
内容都关乎联系人,这么说总能自圆其说。这些描述并没有错。
它们只是高度相关。

编目完成之后,contact 这个词出现在了1,245 篇文档中有约
1,072 篇
里——这个数字我可以根据得到的 IDF 反推出来,
也就是 0.15

作为对比,同一个 schema 里一个真正罕见的词会得到这样的结果:

包含该词的文档数 idf
contact,编目之后 1,245 篇中约 1,072 篇 0.15
contact,仅统计 name 字段 1,245 篇中的 17 篇 4.27
score += idf(t) * f * (k1 + 1) / (f + k1 * (1 - b + b * len / avg_len))

问哪个对象在 CRM schema 里拥有最长的文档,答案是最核心的那一个。这个 schema 里的 contacts 有 55 列,
再加上一段自动生成的描述和一排别名词,它的文档就比语料平均值长出好几倍。
contact_import_log 只有六列,描述也只有一行,
所以它短小、整洁,而且——单就长度先验而言——
反而更关于联系人。

于是,两种效应朝同一个方向叠加:

  • IDF 坍缩抹掉了本可以把 contacts 与另外四十张提到联系人的表区分开的那部分信号。
  • 长度归一化接着会主动把剩下的候选按逆中心性排序,因为 schema 里最重要的表,几乎注定就是列最多的那张。

编目并没有引入噪声,它引入的是相关噪声,而相关噪声攻击的恰恰
是它本想帮衬的那类查询。退化最严重的问题,正是编目功能要服务的对象——
大白话,不带任何 schema 词汇。直接点名表名的问题大多安然无恙,
因为它们手里握着一个稀缺词可以抓。这是相当讨厌的一种失败模式:
功能在冒烟测试里看起来一切正常,到了真实用户手上却失灵。

行不通的修法

最直觉的应对是少信描述:仍然只用一个索引,但把
生成文本的权重压到真实文本之下。

我最初就是这么干的。它有点帮助,却拉错了杠杆,个中原因我花了
好一阵才看明白:加权与稀释作用在不同的阶段。

降权是在 IDF 已经在被描述污染过的语料上算完之后,才去缩放词项的贡献。
contact 在名称自身得分里的价值依然是 0.15,因为名称和描述同处一个词袋,
而 IDF 是词袋的属性。你只是把坏信道的音量调小了,
却没能让好信道重新变得准确。

而代价是真金白银的。饿瘦散文权重让我丢掉了
“per member per month cost” → v_pmpm 这条命中——它是整个 schema 里
最典型的“只有描述才能回答的问题”。从那句话到那个名字之间,
词面上无路可走。描述是唯一的桥,而我刚刚断了这座桥的经费。

所以说:降权是拿掉收益去换取损失的部分缓解。
你到头来调的那个标量,只会让两头都比本可以做到的更糟。

行得通的修法

把各字段分开打分,再融合各自的排名,
而不是把字段拼在一起、只打一次分。

对同一批对象建三个 BM25 索引:

self._bm25       = _BM25([doc.embed_text() for doc in docs])   # everything
self._bm25_name  = _BM25([_name_text(doc)  for doc in docs])   # identifiers
self._bm25_prose = _BM25([_prose_text(doc) for doc in docs])   # written text

其中

def _name_text(doc):
    """Just the identifiers: schema, name, and the name split on underscores."""
    return " ".join(x for x in (doc.schema, doc.name,
                                doc.name.replace("_", " ")) if x)

def _prose_text(doc):
    """Everything written *about* the object: hint, description, comments."""
    parts = [doc.hint or "", doc.description or ""]
    parts.extend(c.comment or "" for c in doc.columns)
    return " ".join(x for x in parts if x)

采用倒数排名融合而非按分数融合:

for q in candidates:
    s = 0.0
    if q in vec_rank:   s += vector_weight  / (RRF_K + vec_rank[q]   + 1)
    if q in lex_rank:   s += lexical_weight / (RRF_K + lex_rank[q]   + 1)
    if q in name_rank:  s += NAME_WEIGHT    / (RRF_K + name_rank[q]  + 1)
    if q in prose_rank: s += PROSE_WEIGHT   / (RRF_K + prose_rank[q] + 1)

这个 bug 的两半同时消失,值得把原因讲清楚,
因为"改用按字段搜索就行"这类建议,人们往往只给结论,
不给机制:

IDF 按字段重新计算。在名称索引里,唯一的文本就是标识符,
模型写出的任何内容都进不了这个字段。contact 出现在 1,245 个名称中的 17 个里,
它的 IDF 因此是 4.27 而非 0.15——区分能力达到原来的 28 倍,
而且是靠结构本身恢复的,不靠调参。

文档长度同样按字段计算。名称索引的文档长度,就是名称本身的长度,
不管往对象上挂多少别的东西,contacts 始终只是两个词元。
55 个字段撑不大它,长度先验也就不再惩罚
位于中心位置的词。

融合基于排名,而非得分。目录写得再烂,问题也被关在
这一环里,这正是我能把正文权重调回对等水平的原因。每个通道
能贡献的只有它自己的那份排名。假如一个弱模型给全部 1,245 个对象
都写上"存储用户及其设置的数据",正文通道就会彻底失效——
所有对象排名相同,这个通道提供不了任何有区分度的信息,
最终结果照旧由名称通道和正文通道决定。兜底下限从
"比建目录之前更差"变成"顶多和建目录
之前持平"

最后这个性质才是我真正在意的。它意味着让一个小型本地模型
来描述你的 schema 是安全的。不一定好——1.5B 模型
写出的描述比前沿模型差得多,我更希望你用
好的那个。但至少安全:糟糕的描述再也埋不掉
它所描述的对象,尝试的代价因此有了上限。

这个模式能推广到哪里

这个模式并不限于数据库。它是这样的:

针对语料库生成的文本,用的都是语料库自己的词汇,
所以拿生成内容做增补,会抬高领域核心词——也就是用户搜索时用的那些词——
的文档频率;而且越是重要的条目,
文档长度被撑得越厉害。

只要在同一个字段里,
把生成的文本和原始文本放在一起建索引,这两种效应就都会找上门:

  • 拼接在分块前面的摘要。在一个关于 Kubernetes 的语料库里,每条摘要都会提到"Kubernetes"。
  • 假设性问题生成(HyDE 式索引)。你在大规模地为每一篇文档合成用户自己的措辞。这等于把 IDF 稀释做成了产品功能。
  • 关键词和同义词扩展。同样的形态,只是更集中。
  • LLM 生成的标题或替代文本合并进正文字段。

这些做法本身都不是坏主意。我依然会给 schema 编目;用业务措辞提问时,
带描述的召回率远好于不带。我的主张其实更窄:
富化内容永远该放进独立的字段。拆分字段的代价
是多建一个索引、多做一步融合;而不拆分的代价,
是一种只在你最重要的查询上才显现的性能退化,
看上去就像“检索本来就很难”。

想知道这种事有没有发生在你身上?只需一条查询,
不需要任何监控工具:取出用户实际会输入的十个名词,
打印它们在富化步骤前后的文档频率。要是
其中某个词如今出现在超过一半的文档里,那它已经起不到任何作用——
而它之前多半还能起点作用。

它解决不了的问题

下面坦白几处边界,毕竟上文读起来比实际折腾的那一周要干净利落得多:

分字段评分无法把一个糟糕的目录变好,只能让它变得无害。如果
你的描述写得笼统,你拿到的将是编目之前的排序效果,
而不是更好的排序——这本是正确的结局,但别把它当成通行证,
从此跳过对生成这些描述的模型的评估。

它还给每个字段引入了一个可调参数,而我并没有一套有原则的方法
来设定它们。我的参数全部设成相等,是因为这在六个 schema 上实测效果最好,
而不是因为权重相等在理论上是正确的。

另外,拆分字段也救不了那些在名称中同样真实高频的词。
一个有 300 张表、而且真的都叫 contact_something 的 schema,
本身就存在实打实的歧义问题,再怎么隔离字段,
也变不出解决这个歧义所需的信息。


文中的测量数据来自 schemagate,这是一个开源库(Apache-2.0),
负责 text-to-SQL 的检索环节。相关代码
catalog.py
里——那三处 _BM25 构造旁边的注释,就是我趁记忆还新鲜时
当场记下的。另外还有一个浏览器版演示:
ashishsinha1602.github.io/schemagate
它在六个示例 schema 上于客户端直接运行真正的选择器。如果你更想
亲手摆弄一下排序,而不是只读文字描述,不妨去试试。

如果你在自己的语料库上运行文档频率检查,我很想知道
它说了什么 — — 特别是如果它说没有问题,因为我想知道
了解是什么让语料库免疫。

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

ai llm rag search