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
← 上一篇
如何构建自评估AI系统:LLM应用的自动化测试与评估流水线
↑