目录化步骤本来应该是易得的胜利。你有一个模式,其
表名为 ecm_template_link 和 v_pmpm,用户用英语提问,而这两套词汇之间的差距导致了检索失误。于是你让模型逐个表进行指向,得到每个表的一句描述,并将这些描述与表名一起建立索引,此时语料库也会说英语了。
我曾在一个真实的 1,245 对象模式上这样做过。召回率 下降。
不仅仅是一点点。名为 contacts 的表在排名第 3 的
问题 “显示 xmagnet 的联系人” 在目录化之前。之后
目录化后它的排名降到 40 之后 — — 超出了我愿意放入提示词的任何内容的范围。提示词。描述很好。我读了它们。它们准确,
具体,并且它们让系统变得更糟。
这篇文章实际上在描述发生的事情,显而易见的修复方法不起作用,以及有效的方法。这与 text-to-SQL 无关。如果你
在索引之前丰富文档 — — 摘要、生成的标题,
假设性问题、关键词扩展、任何内容 — — 同样的机制是
可用来咬你,而且它不会自己暴露。
机制,一句话概括
你生成的每个描述都使用与每个
其他描述相同的词汇,因此丰富会提高文档频率,正好是
你的用户输入的词汇。
idf(t) = log(1 + (N - n_t + 0.5) / (n_t + 0.5))
N 是语料库的大小,n_t 是包含该术语的文档数量。
在少数文档中出现的术语信息丰富且得分高;在多数文档中出现的术语几乎无价值且得分接近零。这就是 IDF 的全部意义,通常也是一种很好的直觉。
现在想想当你让模型描述 CRM 模式中的一个表时,它会写些什么。它写的是联系人相关的内容。当它描述 contacts 时,会写联系人;同样,在描述 contact_lists、campaign_recipients、email_events、tenants、users 以及记录它们变更的审计表时,也会写联系人——因为在 CRM 中,几乎所有内容在某种合理意义上都围绕着联系人。这些描述并不错误。它们是相关的。
编目后,标记 contact 在大约 1,072 份中的 1,245 份文档中出现——这个数字我可以根据它产生的 IDF 来还原,该 IDF 为 0.15。
为了作对比,以下是同一模式对真正罕见术语的结果:
| 术语 | 包含该术语的文档数 | IDF |
|---|---|---|
contact, 编目后
|
约 1,072 份中的 1,245 份 | 0.15 |
contact, 仅在 name 字段中
|
17 份中的 1,245 份 | 4.27 |
tenant, 仅在 name 字段中
|
30 份中的 1,245 份 | 3.71 |
the |
0 份中的 1,245 份 | — |
分词器不会去除停用词,因此 the 和 and 也在此索引中,因为它们出现在所有文档中,得分接近零。在 0.15 得分时,contact 也加入了它们。用户输入的词,就评分而言,是一个功能词。
后半部分,情况更糟
仅 IDF 崩塌会使排名变平。实际将其反转的是长度归一化。
BM25 中的 b 参数会惩罚长文档,基于这样的理论:长文档若包含你的术语,相比短文档,其对该术语的“关于度”更低:
score += idf(t) * f * (k1 + 1) / (f + k1 * (1 - b + b * len / avg_len))
询问在 CRM 模式中哪个对象的文档最长,答案是
中心的那个。contacts 在此模式中有 55 列。添加生成的
描述和一行别名词后,其文档长度是语料库平均值的几倍。与此同时,contact_import_log 有六列和一行描述,因此它简洁、整洁,并且 — — 就长度先验而言 — — 更多地 关于 contacts。
因此这两种效应在同一方向上叠加:
- IDF 崩溃移除了原本能够将
contacts与其他提到 contacts 的四十张表区分开来的信号。 - 随后长度归一化会主动按逆中心性对剩余内容进行排序,因为在一个模式中,最重要的表可靠地就是列数最多的那张表。
目录编目并未引入噪声。它引入了 相关 噪声,而相关噪声会攻击它本应帮助的确切查询。受影响最严重的问题是目录编目本应服务的那些问题——纯英文,不含模式词。直接命名表格的问题大多没问题,因为它们拥有一个罕见的 token 可依赖。这是一种令人讨厌的失败特征:该功能在你的冒烟测试中看起来正常,但在实际用户中却会失效。
行不通的修复
显而易见的做法是降低对描述的信任。使用一个索引,但将生成的文本权重设置得低于真实文本。
我最初就这样做了。这确实有点帮助,但这是错误的杠杆,原因我过了好一会儿才意识到:权重和稀释作用于不同的阶段。
降权会在 IDF 已经在描述污染的语料库上计算完成之后,调整词项的贡献。contact 在名称自身得分中仍然价值 0.15,这是因为名称和描述位于同一个词袋中,而 IDF 是该词袋的属性。你让错误的通道变得更安静,却没有让正确的通道再次变得准确。
而且代价是真实存在的。削弱散文权重让我失去了 “每成员每月成本” → v_pmpm,这是整个模式中唯一能由描述回答的问题的最佳示例。该短语与该名称之间没有词汇路径。描述是唯一的桥梁,而我刚刚削减了它的资金。
因此:降权会牺牲部分收益来减轻损失。你最终会调整一个标量,使得两方面的表现都变得不如所需。
行得通的修复
分别对字段进行评分,然后融合排名,而不是先拼接字段再进行一次评分。
针对同一对象的三个 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 无论挂多少其他属性,都只有两个 token。
那 55 个列没法把它撑大,于是长度先验不再惩罚居中的对象。
融合基于排名而非分数。这正是劣质目录问题所在,
也是我能把正文权重调回对等水平的原因。每个通道只能贡献自己的排序。
就算弱模型给全部 1,245 个对象都写上“存储用户及其设置的数据”,
正文通道也就变成了均匀的噪音——所有对象排名相同,
这个通道提供不了任何区分度,结果就由名称和正文通道照常决定,分毫不变。
下限从“比建目录之前更糟”变成了“最多和建目录之前一样”。
最后这条性质才是我真正看重的。它意味着让小尺寸本地模型去读你的 schema 是安全的。
不一定效果好——1.5B 模型写的描述肯定比前沿模型差不少,我也希望你用好的那个。
但安全:糟糕的描述再也没法把它描述的对象埋掉,试错的损失是有上限的。
这个规律能推广到哪里
这个模式不只针对数据库。它的本质是:
围绕语料库生成的文本,用的正是语料库自己的词汇,
于是内容增强推高了领域核心词汇——也就是用户搜索时用的那些词——的文档频率,
而越重要的条目,文档长度被推高得越厉害。
只要你生成文本,并把它和原始文本放进同一字段一起索引,这两种效应就都跑不掉:
- 拼接在文本块前面的摘要。关于 Kubernetes 的语料库里,每条摘要都会提到"Kubernetes"。
-
假设性问题生成(HyDE 式索引)。你是在大规模合成用户自己的问法,
覆盖每一篇文档。这等于把 IDF 稀释做成了产品功能。 - 关键词和同义词扩展。同样的形状,只是更集中。
- 大模型写的标题或 alt 文本混入正文字段。
这些都不是坏主意。我仍然为模式建目录;在业务表述的问题上,加描述后的召回效果远好于不加描述时。该主张更为具体:丰富信息应始终放在独立字段中。分离字段需要额外一个索引以及融合步骤。不分离它们会导致回归,仅在最重要查询中出现,表现为“检索很难”。
如果你想检查这是否正在发生,只需一次查询,无需额外仪器。取用户实际输入的十个名词,并打印它们在丰富步骤前后文档频率。如果其中任何一个现在出现在超过一半文档中,则该术语不起作用,之前可能有作用。
它无法解决的问题
关于边界的诚实,上面的表述比一周前更清晰:
字段评分不能让糟糕目录变好。它只能使其无害。如果描述泛泛而谈,你会得到未编目前的排名,而不是更好排名——这是正确结果,但不要把它视为跳过评估生成它们模型的许可。
它还为每个字段引入了一个可调节参数,但我没有原则性方法来设置它们。我的所有参数都设为平等,因为在六种模式上测试效果最好,而不是因为平等在理论上正确。
并且分离字段也无法修正名称中真正常见的术语。一个实际上有300张表且命名为 contact_something 的模式存在真实歧义问题,无论怎样隔离字段都无法产生解决它所需的信息。
这里的测量数据来自 schemagate,这是一个开源库(Apache-2.0),负责 text-to-SQL 检索步骤。相关代码位于 catalog.py——我在此记录了下来,当时仍记忆犹新,注释就在三个 _BM25 构造周围。有一个浏览器演示位于 ashishsinha1602.github.io/schemagate,它在六个示例模式上客户端运行真实的选择器,如果你更愿意研究排名而非阅读说明的话。
如果你在自己的语料库上运行文档频率检查,欢迎告诉我结果——尤其是当它显示一切正常的时候,因为我很想知道什么样的语料库才能对此免疫。