向量|ES 的重打分能写进 mapping,rescore_vector 填错会报错

9.1 给 dense_vector 的索引选项加了 rescore_vector 参数。把"要不要重打分、过采样多少"写进 mapping,就不用每个查询都带一遍。它的取值有硬校验:小于 1 会报错,0 是特例,最大 10。

说明

实测环境:本地起的 Elasticsearch 9.4.2 单节点实例(官方 tar 包直接跑,堆内存 512MB,安全与机器学习关闭),curl 连本地 9200 端口。本文「实测」小节的命令和输出都出自这个实例。

来源和边界先交代:本文的依据出自 v9.4.2 标签下的官方文件(9.1.0.yml、DenseVectorFieldMapper.java、KnnRetrieverBuilder.java),文中引用的原文均来自这些文件。示例取自官方示例;哪些是文档写明、哪些是我的推断,正文里逐处标注。

图 1:官方文件 9.1.0.yml(v9.4.2 标签)。来源:GitHub elastic/elasticsearch v9.4.2 9.1.0.yml

官方条目

9.1 的 changelog 标为 enhancement(9.1.0.yml 第 496 行 @ v9.4.2),PR 124581:

New `vector_rescore` parameter as a quantized index type option

只有一行 summary。下面的内容全部来自源码——而且源码写得非常清楚。

图 2:官方文件 DenseVectorFieldMapper.java(v9.4.2 标签)。来源:GitHub elastic/elasticsearch v9.4.2 DenseVectorFieldMapper.java

先说它解决什么问题

前面写过 rescore_oversample 填 0,讲的是 ES|QL 里 rescore_oversample 可以填 0 来跳过重打分。那是在查询侧控制。

查询侧控制有个不便:每个查询都要带一遍参数。如果某个字段的重打分策略是固定的(比如"这个字段一律按 3 倍过采样重排"),那每次都写就太啰嗦了。

本篇的改动把这件事挪到了 mapping 侧:写一次,之后所有针对这个字段的查询都默认用它。

图 3:官方文件 KnnRetrieverBuilder.java(v9.4.2 标签)。来源:GitHub elastic/elasticsearch v9.4.2 KnnRetrieverBuilder.java

源码里的定义

RescoreVector 是个 record,定义在 DenseVectorFieldMapper.java 第 2761–2762 行(源码 @ v9.4.2):

public record RescoreVector(float oversample) implements ToXContentObject {
    static final String NAME = "rescore_vector";
    static final String OVERSAMPLE = "oversample";

结构很清楚:一个对象,里面一个 oversample 字段。

取值校验是本篇最有价值的部分

解析逻辑在同一处(第 2764–2783 行),三条校验规则写得极其明确:

Object oversampleNode = mappedNode.get(OVERSAMPLE);
if (oversampleNode == null) {
    throw new IllegalArgumentException("Invalid rescore_vector value. Missing required field " + OVERSAMPLE);
}
float oversampleValue = (float) XContentMapValues.nodeDoubleValue(oversampleNode);
if (oversampleValue == 0 && allowsZeroRescore(indexVersion) == false) {
    throw new IllegalArgumentException("oversample must be greater than 1");
}
if (oversampleValue < 1 && oversampleValue != 0) {
    throw new IllegalArgumentException("oversample must be greater than 1 or exactly 0");
} else if (oversampleValue > 10) {
    throw new IllegalArgumentException("oversample must be less than or equal to 10");
}

逐条翻译:

一,oversample 是必填的。 给了 rescore_vector 却不给 oversample,报 Missing required field oversample。

二,必须大于 1,或者恰好等于 0。 中间地带(0 到 1 之间,比如 0.5)不被接受,报错文案直接写了 "must be greater than 1 or exactly 0"。

三,最大 10。 超过 10 报 "must be less than or equal to 10"。

四,0 还受版本限制。 注意第一条校验里的 allowsZeroRescore(indexVersion)——在不支持的版本上填 0 会被拒,报错是 "oversample must be greater than 1"。

第四条和 rescore_oversample 填 0 讲的那条是同一件事:oversample=0 表示跳过重打分,是 9.1 才有的能力。这里再次遇到它,而且能看到它的适用范围被版本控制着。

为什么要有这些边界

把这些限制串起来看,逻辑是一致的:

  • 必填 —— 给了 rescore_vector 却不告诉过采样多少,等于没说;
  • 不能小于 1(除非是 0) —— 小于 1 意味着"取的候选比最终要的还少",语义上就是错的:重排的候选池应该比结果集大才有意义;
  • 最大 10 —— 防止设得过大导致重排开销爆炸;
  • 0 需要版本支持 —— 新能力,老版本不认。

"不能小于 1(除非 0)"这条最值得琢磨:它说明 0 和"很小的正数"是两种完全不同的东西——前者是开关(跳过),后者是无效配置。这跟 rescore_oversample 填 0 里说的"0 是开关不是数值"完全一致。

和查询侧参数的关系

rescore_vector 这个名字在两处出现:

  • mapping 侧:本篇,索引选项里的参数;
  • 查询侧:KnnRetrieverBuilder.java 第 54 行(源码 @ v9.4.2):
public static final ParseField RESCORE_VECTOR_FIELD = new ParseField("rescore_vector");

同名,两处。 判断这是有意的:查询侧的写法可以覆盖 mapping 侧的默认值。

(说明:"查询侧覆盖 mapping 侧"是推断,依据是同名参数和常见的配置优先级设计。未逐个确认覆盖逻辑,所以如果你依赖这个行为,建议实测。)

如果确实能覆盖,用法就很自然:mapping 里定一个适合大多数场景的值,个别查询需要不同策略时自己带一个。

相关的 capability

还有一个间接证据说明这条能力的存在(SearchCapabilities.java 第 47 行,源码 @ v9.4.2):

private static final String KNN_QUANTIZED_VECTOR_RESCORE_OVERSAMPLE = "knn_quantized_vector_rescore_oversample";

从名字能看出两件事:

  • 它是量化向量(quantized)相关的;
  • 它是 oversample 相关的。

"quantized" 这个限定很重要:重打分只在向量被量化时才有意义——如果存的是原始 float32,就不需要"用原始向量重排"这一步了。这一点和 rescore_oversample 填 0 里写过的一致。所以配这个参数前,先确认字段用的是量化索引类型。

"不能小于 1"这条为什么合理

前面列了四条边界,其中"必须大于 1 或恰好 0"值得解释,因为它是唯一一条不能靠常识推出来的。

  • 必填 —— 显然;
  • 最大 10 —— 防滥用,能理解;
  • 0 要版本支持 —— 新能力,能理解;
  • 但为什么 0.5 不行?

原因在"过采样"这个词的定义里。

过采样的意思是:取比最终需要的更多的候选。如果最终要 10 条,取 30 条去重排,就是 3 倍过采样。

那 0.5 倍意味着什么? 取 5 条候选,重排后返回 10 条——候选不够,重排之后还是只有 5 条可用,凑不出 10 条。

所以小于 1 的过采样在语义上是自相矛盾的:重排的前提是候选池比结果集大,否则重排没有意义。

而 0 为什么可以? 因为它根本是"跳过整个重排环节",不是"0 倍过采样"(rescore_oversample 填 0 讲过这个)。它是一个开关——所以能跳出"必须大于 1"的规则,不是一个数值。

这两者的区别,源码用两行判断表达得很清楚:

  • oversampleValue == 0 && allowsZeroRescore(indexVersion) == false → 版本不支持时报"必须大于 1";
  • oversampleValue < 1 && oversampleValue != 0 → 明确排除了 0,其它小于 1 的值都报错。

第二条里的 && oversampleValue != 0 就是关键——它把 0 单独摘出去了。

写这一段是因为:理解规则背后的原因,比记住规则本身有用。遇到报错时,"为什么要这么限制"想明白了,改法自然就有了。

怎么配

按源码的结构,mapping 里大致是这样(下面的写法按参数定义构造,未实际执行):

PUT my-vector-index
{
  "mappings": {
    "properties": {
      "my_vector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine",
        "index_options": {
          "type": "bbq_hnsw",
          "rescore_vector": {
            "oversample": 3
          }
        }
      }
    }
  }
}

关键点:rescore_vector 在 index_options 里,oversample 必填,值在 0 或 [1, 10] 范围内。

(说明:index_options 里各字段的确切位置和写法未核对,源码里能看到解析逻辑,但完整的 mapping 结构以你版本的文档为准。)

判断

这条改动的类型很典型:把一个查询参数提升为字段级配置。

好处:

  • 不用每次查询都带;
  • 策略集中,改一处生效全部;
  • 不会因为某个查询漏带参数而行为不一致。

代价:

  • 灵活性下降——个别查询想用不同策略,得靠覆盖机制(而覆盖行为未确认);
  • 默认值藏得更深了——以前看查询就知道过采样多少,现在还要看 mapping。

对这个交换的评价:对"策略固定"的场景是纯收益,对"策略多变"的场景反而更绕。

建议:如果重打分策略基本固定 → 写进 mapping,省事;如果经常变 → 保持查询侧传参,别放进 mapping。

和查询侧的 rescore_oversample 合起来看

这两篇讲的是同一个参数的两个侧面,放一起才完整:

rescore_oversample 填 0本篇
位置查询侧(ESQL 的 rescore_oversample)
讲的重点0 的特殊含义取值校验规则
生效范围单次查询整个字段

两篇都指向同一条边界:oversample 要么大于 1,要么是 0,中间没有值。

这个一致性让人放心——说明 ES 在不同入口对这个参数的语义理解是统一的,不是各实现一套。写文档或排查时,一个入口的规则可以类推到另一个。

在 9.4.2 上跑一遍

同一个 rescore_vector.oversample,一个写数字一个写字符串:

图 4:本地 9.4.2 单节点实例上的实测输出。上:oversample: 2.0;下:oversample: "bad"

curl -s -X PUT "http://localhost:9200/rv-ok" \
  -H 'Content-Type: application/json' \
  -d '{"mappings":{"properties":{"v":{"type":"dense_vector","dims":3,"index_options":{"type":"int8_hnsw","rescore_vector":{"oversample":2.0}}}}}}'

curl -s -X PUT "http://localhost:9200/rv-bad" \
  -H 'Content-Type: application/json' \
  -d '{"mappings":{"properties":{"v":{"type":"dense_vector","dims":3,"index_options":{"type":"int8_hnsw","rescore_vector":{"oversample":"bad"}}}}}}' \
  | jq -r '"HTTP \(.status): \(.error.root_cause[0].reason)"'

写数字的那条 mapping 建起来了;写字符串返回 HTTP 400: Failed to parse mapping: For input string: "bad"。

这句报错有个麻烦的地方:它是 mapper_parsing_exception 包着一个 NumberFormatException,外层没有提到字段名。一次改多个 index_options 时,看不出来是哪个向量的哪个参数写错了。

结论是:mapping 生成模板里如果这个值是从配置读的,落库前先做一次数值校验——等 ES 报这句再查,得把所有向量的 mapping 翻一遍。

写在最后

一条把配置位置往前挪的改动。

一,rescore_vector.oversample 现在可以写进 index_options,作为字段级默认值,不用每个查询都带。

二,取值有三条硬边界:必填、要大于 1(或恰好 0)、不超过 10。报错文案在源码里写得很清楚,排查时直接搜文案就能定位。

三,0 需要版本支持(allowsZeroRescore(indexVersion))。在不支持的版本上填 0 会被拒,报 "must be greater than 1"——这个报错容易让人以为 0 不合法,实际是版本不够。

另外诚实标注三处:"查询侧覆盖 mapping 侧"是推断,未确认;完整 mapping 写法未核对(只确认了解析逻辑);示例未执行。

参考

本文对照 Elasticsearch v9.4.2 标签的 changelog 与源码核对,引文与代码逐字取自原文。**"查询侧可覆盖 mapping 侧"**是推断,依据同名参数与常见配置优先级设计,未确认;完整 mapping 结构未核对,仅确认解析逻辑;图 4 的两次 mapping 创建已在本地 9.4.2 单节点实例上执行,报错为完整原文。

0
0
0
0
评论
未登录
暂无评论