Skip to main content
Orux AIOruxAI

Orux AI / 模型观察

Embedding API 迁移:重建向量索引与可回退切换

从 text-embedding-3-small 迁移到 large,不能只补齐向量维度。用离线 Python 验证六类错误,核对重建费用、存储占用与编码器和索引成对切换方案。

Orux AI发布于 10 分钟阅读
Embedding API 迁移:重建向量索引与可回退切换 — 从 text-embedding-3-small 迁移到 large,不能只补齐向量维度。
文章目录

换嵌入模型,就要为检索重建索引

从 text-embedding-3-small 迁移到 text-embedding-3-large 时,应当用原始文档文本建立独立的新索引,查询也要使用与它匹配的嵌入配置。不要给旧向量补零来凑长度,也不要因为两个向量维度相同,就认为它们使用相同的坐标系。在新文档和查询链路通过自己的检索评估之前,保留旧索引。

对于准备迁移产品帮助中心搜索的文档团队,这意味着需要预算一次完整语料的重新嵌入、两套索引并存的临时存储,以及可以回退的配置切换。下方可运行的 Python 示例演示了为什么只检查维度还不够,并拒绝六种无效输入,算出一份具体的重建预算。程序完全离线运行:其中的二维向量是手写几何数据,不是这两个 OpenAI 模型的输出。

旧坐标系和旋转后的新坐标系各自检索都得到A,混用则得到B。
旧坐标系和旋转后的新坐标系各自检索都得到A,混用则得到B。

手写向量在各自坐标系内保持正确结果。查询与索引混用时,即使向量长度相同,也会返回 B。示意图依据已执行的本地示例绘制。

模型维度与当前 Orux 的功能边界

OpenAI 嵌入指南列出的默认向量长度为:text-embedding-3-small 是 1,536,text-embedding-3-large 是 3,072。文档还说明第三代嵌入模型原生支持通过 dimensions 参数缩短向量。缩短受支持模型的输出是一种配置选择,不会把这个模型变成另一个模型。

这两个准确的 alias 当前都已在 Orux 上架。text-embedding-3-small 模型页和 text-embedding-3-large 模型页提供文本输入与 float 向量格式。其公开保存的示例分别记录了 1,536 和 3,072 维。这些示例可以支持输出形状的说明,不能证明哪个模型在一般任务上准确率更高。

2026 年 11 月 3 日核对的表单没有提供维度控制项。因此,不能仅凭 OpenAI 原生文档就规划一个缩短维度的 Orux 索引。依赖该选项之前,应确认具体接入约定。本文既未发起付费嵌入请求,也未验证 Orux 对维度参数的转发行为。当前接入方式请查看 Orux API 文档。

应当把模型 alias、实际返回的维度数、文本预处理版本和距离度量一起记录。例如,用 title + body 构造帮助文档文本,与只嵌入 body 的预处理版本应当不同。还要在分块标识旁保存文档修订哈希,便于后续重建时识别过期文本。维度检查能发现明显的形状错误;其余配置字段能发现形状检查识别不了的配置错误。

用二维例子揭示不易发现的不匹配

设旧坐标系中有两个文档向量:A 为 (1, 0),B 为 (0, 1)。旧查询 (1, 0) 会检索到 A。现在把整个坐标系旋转 90 度:A 变为 (0, 1),B 变为 (-1, 0),同一概念对应的查询变为 (0, 1)。只要在各自坐标系内保持一致,两边检索都会得到 A。

然而,把旋转后的查询与旧文档混用,胜出的就成了 B。所有向量仍然只有两个坐标。计算可以顺利完成,检索含义却变了。这是对“相同维度意味着兼容”这一假设的数学反例,不是对任何一个模型学习行为的模拟,也不是实际迁移失败率的估计。

给旧向量补零只改变长度,并未提供通往新模型所学表示空间的映射。截断同样不能建立跨模型兼容性。即使是在同一个受支持模型内缩短向量,文档与查询也应遵循一致的维度策略,并评估缩短后的检索表现。原生支持缩短向量,不代表可以随意混用新旧表示。

示例中的检查函数会先比较完整配置,再计算余弦相似度。这个检查有一个前提:元数据必须真实。故意把新查询标为旧查询,就能绕过它。下方的 unsafe_mixed_top 结果正是刻意展示这一限制。生产环境的来源信息应来自编码器配置和存储链路,不能只相信调用者未经核验的声明。

重建预算:按语料用量计算,而不是按向量个数

以下价格核对日期为 2026 年 11 月 3 日。Orux 售价来自两个公开模型记录;OpenAI 直连的标准输入价来自其 small 模型价格与 large 模型价格。这是不同的购买方式,不代表账户条款或运行行为完全一致。

当前配置的换算关系为 1 Credit = USD 0.005。表中的 USD 是算术换算值,Orux 使用 Credits 计费。所有单价的单位都是每百万输入 tokens,而非每百万篇文档。表格不包括单独的存储、索引托管和工程费用,也没有假设 Batch 折扣或免费重建。

模型Orux 输入单价/百万 tokensOpenAI 直连标准单价/百万 tokensOrux 计划重建 500 万 tokens
text-embedding-3-small1.53336 Credits(USD 0.0076668)等值 4 Credits(USD 0.02)7.6668 Credits(USD 0.038334)
text-embedding-3-large9.96672 Credits(USD 0.0498336)等值 26 Credits(USD 0.13)49.8336 Credits(USD 0.249168)

左右滑动查看完整表格。

假设规划中的语料为 10,000 个分块,每块平均 500 输入 tokens,总量就是 500 万 tokens。将整份语料迁移到 large,需要预算 large 的完整重建费用,不能只支付两行价格的差额。此前在 small 上的花费并没有购买 large 向量。在这次快照中,Orux large 单价约为 small 的 6.5 倍;这个比值不能说明它在你的文档上能带来多少检索改善。

以上金额以假设的 token 数和汇总算法估算。实际分块长度、查询流量、重试和计费粒度都可能改变支出。授权真实运行前,应使用对应的 tokenizer 测量语料用量。可执行代码使用整数 micro-Credits 计算费用,再用 Decimal 展示,避免以二进制浮点处理金额。

内存需要单独估算。使用 float32 时,10,000 条向量在 1,536 维下需要 61,440,000 字节,在 3,072 维下需要 122,880,000 字节,约为 58.59 MiB 与 117.19 MiB。这些只是原始坐标数组。元数据、标识符、检索图结构、副本和序列化还会增加开销。为保证迁移可以回退,同时保留两套原始数组就需要 184,320,000 字节,尚未计算其他开销。

运行兼容性与预算示例

把下列内容保存为 migration_demo.py,使用 Python 3.9 或更高版本运行 python3 migration_demo.py。它只使用标准库,不发起网络请求。价格保留为注明日期的快照,便于复现计算;以后规划迁移时,应替换为核实过的当前单价。

Python
import json
import math
from dataclasses import asdict, dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Contract:
    model: str
    dimensions: int
    preprocessing: str
    metric: str = 'cosine'

def validate(vector, contract):
    if len(vector) != contract.dimensions:
        raise ValueError('dimension mismatch')
    if any(isinstance(x, bool) or not isinstance(x, (int, float))
           or not math.isfinite(x) for x in vector):
        raise ValueError('non-finite or non-numeric vector')
    if math.hypot(*vector) == 0:
        raise ValueError('zero vector')

def search(query, query_contract, documents, index_contract):
    if query_contract != index_contract:
        raise ValueError('embedding contract mismatch')
    validate(query, query_contract)
    scores = []
    for doc_id, vector in documents.items():
        validate(vector, index_contract)
        score = sum(a*b for a, b in zip(query, vector)) / (
            math.hypot(*query) * math.hypot(*vector))
        scores.append((doc_id, score))
    return sorted(scores, key=lambda row: (-row[1], row[0]))

def budget(rate_micro_credits, total_tokens):
    micro = (rate_micro_credits * total_tokens + 999999) // 1000000
    credits = Decimal(micro) / 1000000
    return {'credits': str(credits), 'usd': str(credits * Decimal('0.005'))}

def main():
    # Handwritten two-dimensional geometry, NOT model outputs.
    old = Contract('toy-old', 2, 'title-body-v1')
    new = Contract('toy-new', 2, 'title-body-v1')
    old_docs = {'A': [1, 0], 'B': [0, 1]}
    new_docs = {'A': [0, 1], 'B': [-1, 0]}
    old_top = search([1, 0], old, old_docs, old)[0][0]
    new_top = search([0, 1], new, new_docs, new)[0][0]
    unsafe_top = search([0, 1], old, old_docs, old)[0][0]
    assert (old_top, new_top, unsafe_top) == ('A', 'A', 'B')
    failures = [
        lambda: search([0, 1], new, old_docs, old),
        lambda: validate([1], old),
        lambda: validate([0, 0], old),
        lambda: validate([float('nan'), 1], old),
        lambda: validate([True, 1], old),
        lambda: search([1, 0], Contract('toy-old', 2, 'title-body-v2'), old_docs, old),
    ]
    rejected = 0
    for test in failures:
        try:
            test()
        except ValueError:
            rejected += 1
        else:
            raise AssertionError('invalid fixture was accepted')
    chunks, tokens_per_chunk = 10000, 500
    tokens = chunks * tokens_per_chunk
    result = {
        'toy_results': {'old_top': old_top, 'new_top': new_top,
                        'unsafe_mixed_top': unsafe_top},
        'rejected_fixtures': rejected,
        'planning_tokens': tokens,
        'orux_small': budget(1533360, tokens),
        'orux_large': budget(9966720, tokens),
        'raw_float32_bytes': {'small_1536': chunks * 1536 * 4,
                             'large_3072': chunks * 3072 * 4},
        'new_contract': asdict(new),
    }
    print(json.dumps(result, indent=2))

if __name__ == '__main__':
    main()

本地运行得到 old_top: A、new_top: A、unsafe_mixed_top: B,以及 rejected_fixtures: 6。被拒绝的情况分别是:模型配置不同、向量长度错误、零向量、非有限坐标、布尔坐标、预处理版本变化。small 和 large 的规划费用分别为 7.6668 与 49.8336 Credits,与表格一致。

这是兼容性规则和费用计算的可执行演示,不是数据库适配器。它不会获取嵌入、构建近似最近邻索引、证明模型的检索质量,也没有实现生产环境的访问控制。合成坐标的数值较小;生产数值库还应覆盖其允许输入的完整数值范围。

旧版与新版各有一套查询编码器和匹配索引,成对切换便于回退。
旧版与新版各有一套查询编码器和匹配索引,成对切换便于回退。

部署方案:每个查询编码器始终连接匹配的索引;切换和回退都以配对配置为单位。图中展示的是建议架构,不是已经部署的迁移结果。

查询编码器和索引要作为一套配置切换

建立使用目标配置的新集合或命名空间。读取原始文本,按记录的分块和预处理规则处理,再以稳定的文档/分块标识写入目标模型向量。保留现有集合不动。追踪预期分块 ID、已完成 ID 和来源修订,不要只依赖一个已处理行数计数器。

如果重建期间来源文档还在变化,应明确一致性策略。简单的维护窗口快照可以避免并发写入,但会延后更新。在线重建则需要可重放的变更日志或同等的对账机制,让编辑和删除在切换前同步到新索引。向量数量相同并不足够:一个该删除的文档与一个遗漏的文档,可能恰好在数量上抵消。

然后把每个查询编码器绑定到预定索引。不要在应用仍读取 small 集合时,先把查询改为 large。应将这一对作为带版本的应用配置,包含预处理、维度和度量方式。当集合中保存的配置清单与查询配置不一致时,拒绝请求。多租户系统还应在两个集合上保留相同的租户过滤与文档权限。

回退时,需要同时保留旧查询配置与旧集合。如果应用已经不能生成兼容的旧查询,仅保留旧向量也没有用。切换后,观察结果质量、文档缺失报告、延迟和查询费用。只有经过约定的评估和保留窗口,才能停用旧组合,而不是看到新集合接受写入就立即删除旧版。

用标注过的检索问题做决策,不看向量长度猜结果

在全量重建前,先准备一份规模适当、具有代表性的评估集。对于产品帮助搜索,应包含准确零件编号、模糊的故障描述、多语言请求,以及受访问权限限制的文档。为每个问题记录相关文档 ID。相关性判断要与最终生成的回答是否流畅分开。

每种候选配置都应从相同文档快照中检索相同数量的结果。比较相关文档是否进入前列,并检查重要的漏检。不要用短正文片段构建 small 索引,再用补充了标题的文本构建 large 索引,却把全部差异归因于模型。分块、预处理和访问过滤都是独立的实验变量。

本文没有给出实测赢家。当 small 在你自己的评估中满足要求时,可以选择当前输入与存储成本较低的方案。当 large 在你的工作负载上表现出的实际收益足以覆盖重建、更大的向量和持续查询费用时,再考虑它。本地兼容性检查通过,只说明建立了一道防护,不能成为购买更贵模型的理由。

常见问题

更换模型时,必须重新生成全部文档向量吗?

对于本文描述的迁移,是的,应将可搜索语料重新生成为目标模型空间中的向量。保留原始文本与稳定分块 ID,之后的查询也使用同一目标配置。修改模型字段名称不能让旧向量变得兼容。

能把 1,536 维向量补零到 3,072 维吗?

补零可以满足数组长度要求,却不能建立跨模型映射,因此不适合作为本次迁移的兼容方案。二维例子说明:形状合法,仍可能因为混用了坐标系而检索出错误文档。

Orux 是否提供 OpenAI 原生的 dimensions 参数?

核对的这两个 Orux alias 的公开表单没有提供这个参数。只有 OpenAI 原生文档不能验证 Orux 的行为。依赖缩短输出前,应确认实际接入约定,再用一致配置建立和查询索引。

text-embedding-3-large 一定更适合我的搜索吗?

本文没有证明它在你的工作负载上更好。应使用自己的标注问题和同一来源快照评估两套配置。维度更大、价格更高,都不能代替相关文档能否被检索到的测量。

Python 示例实际测试了什么?

它测试了手写向量几何、配置不匹配拒绝逻辑、六种无效输入,以及注明日期的预算与存储计算。它没有调用嵌入 API,没有消耗 Orux Credits,也没有对模型输出或在线向量数据库做基准测试。

在 Orux AI 试用 text-embedding-3-small 模型页在模型页在线试用,并查看价格与 API 示例。打开模型在 Orux AI 试用 text-embedding-3-large 模型页在模型页在线试用,并查看价格与 API 示例。打开模型

用一个 API 调用这些模型

一个 OpenAI 兼容 API,覆盖对话、图像、视频与音频模型,按积分计费。