Skip to main content
Orux AIOruxAI

Orux AI / モデルノート

Embedding APIの移行:ベクトル再構築とロールバック

text-embedding-3-smallからlargeへ、ベクトル空間を混在させずに移行する方法。6つの互換性チェックを実行し、再構築費用・容量・切り替えを比較します。

Orux AI公開 14 分で読めます
Embedding APIの移行:ベクトル再構築とロールバック — text-embedding-3-smallからlargeへ、ベクトル空間を混在させずに移行する方法。
目次

埋め込みモデルを変更するときは検索インデックスを作り直す

text-embedding-3-smallからtext-embedding-3-largeへ移行する場合は、元の文書テキストから別のインデックスを作り、検索クエリもそれに対応する埋め込み設定で処理します。古いベクトルにゼロを足して長さだけを合わせたり、次元数が同じという理由だけで、2つのモデルが同じ座標系を使うと判断したりしてはいけません。新しい文書側とクエリ側の処理が、自社の検索評価に合格するまでは、旧インデックスを残してください。

製品ヘルプの検索サービスを移行するドキュメント担当チームなら、コーパス全体の再埋め込み、2つのインデックスを一時的に保持するストレージ、元に戻せる設定切り替えを計画する必要があります。以下の実行可能なPythonサンプルでは、次元数の確認だけでは不十分な理由を示し、6つの不正なテスト入力を拒否して、再構築の具体的な予算を計算します。処理はすべてオフラインです。使う2次元ベクトルは手作業で定義した幾何学的な例であり、どちらのOpenAIモデルの出力でもありません。

旧空間と回転後の空間ではそれぞれAが検索されるが、空間を混在させるとBが検索される手作業のベクトル例。
旧空間と回転後の空間ではそれぞれAが検索されるが、空間を混在させるとBが検索される手作業のベクトル例。

手作業で定義したベクトルは、どちらの空間でも一貫して使えば正しい結果を保ちます。クエリとインデックスを混在させると、ベクトルの長さが同じでもBが返ります。図は実際にローカル実行したサンプルに基づきます。

モデルの次元数と、現在のOruxで確認できる範囲

OpenAIの埋め込みガイドでは、既定のベクトル長をtext-embedding-3-smallで1,536、text-embedding-3-largeで3,072としています。また、第3世代の埋め込みを短くするネイティブのdimensionsパラメーターも説明しています。対応モデルの出力を短くすることは設定上の選択であり、そのモデルを別のモデルに変える操作ではありません。

Oruxには現在、これら2つの正確なエイリアスが掲載されています。text-embedding-3-smallのモデルページとtext-embedding-3-largeのモデルページには、テキスト入力とfloat形式のベクトルが用意されています。保存済みの公開サンプルでも、それぞれ1,536次元、3,072次元と報告されています。これは報告された出力形状を裏付ける情報であり、モデル間の一般的な精度順位を示すものではありません。

2026年11月3日に確認したフォームには、次元数を指定する操作項目がありません。そのため、ネイティブのOpenAIドキュメントに記載があるだけで、Oruxでも短縮したインデックスを使えると見込まないでください。その機能を前提にする前に、実際の連携仕様を確認する必要があります。この記事では有料の埋め込みリクエストを送っておらず、Orux経由で次元数指定が転送されることも実証していません。現在の連携方法はOrux APIドキュメントで確認してください。

モデルのエイリアス、返却された次元数、テキスト前処理のバージョン、距離指標をまとめて記録します。例えば、title + bodyから作るヘルプ記事の埋め込みには、bodyだけを埋め込む場合と異なる前処理バージョンを付けるべきです。文書のリビジョンハッシュもチャンク識別子と一緒に保存すれば、後の再構築で古いテキストを特定できます。次元数の一致確認で見つかるのは明確な形状エラーです。それだけでは見つからない設定ミスを、残りの仕様項目で検出します。

次元数だけでは見えない不一致を示す2次元の例

簡単な旧空間に、文書ベクトルA=(1, 0)、B=(0, 1)があるとします。旧クエリ(1, 0)で検索するとAが返ります。次に空間全体を90度回転させます。Aは(0, 1)、Bは(-1, 0)になり、概念上同じクエリも(0, 1)になります。どちらの空間でも、その空間のベクトルだけを一貫して使えば、引き続きAが返ります。

ところが、回転後のクエリを旧文書ベクトルと組み合わせると、Bが1位になります。すべてのベクトルは依然として2つの座標を持っています。計算は正常に実行されても、検索の意味は変わってしまいます。これは「次元数が同じなら互換性がある」という仮定に対する数学的な反例です。どちらかのモデルが学習した振る舞いのシミュレーションでも、実際の移行失敗率の推定でもありません。

古いベクトルにゼロを追加すれば長さは変わりますが、新モデルが学習した表現への写像は得られません。切り詰めても、異なるモデル間の互換性は確立されません。対応する1つのモデル内で短縮する場合でも、文書とクエリで次元数の方針を統一し、その結果の検索動作を評価してください。ネイティブの短縮機能があるからといって、新旧の表現を無条件に混ぜてよいわけではありません。

サンプルのガードは、コサイン類似度を計算する前に設定全体を比較します。ただし、メタデータが正しいことが前提です。新しいクエリを意図的に旧設定として扱えば、ガードを回避できます。後述のunsafe_mixed_topは、この限界を意図的に示す結果です。本番環境の来歴情報は、呼び出し元の未検証の申告ではなく、エンコーダー設定と保存経路から取得する必要があります。

再構築費用はベクトル本数ではなくコーパスで見積もる

以下の価格は2026年11月3日に確認しました。Oruxの料金は2つの公開モデルレコードに基づきます。OpenAI直接利用の標準入力料金は、smallモデルの価格とlargeモデルの価格に基づきます。これらは別々の購入方法であり、アカウント条件や運用上の動作が同じだと主張するものではありません。

現在設定されている換算は1 Credit = USD 0.005です。以下のUSDは計算上の換算額であり、Oruxの課金単位はCreditsです。料金の単位はすべて入力100万トークンあたりで、100万文書あたりではありません。表にはストレージ、インデックスのホスティング、開発作業の費用を含めず、Batch割引や無料の再構築も前提にしていません。

モデルOrux入力料金/100万トークンOpenAI直接利用の標準料金/100万トークンOruxで500万トークンを再構築する計画額
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)

横にスワイプすると表全体を確認できます。

平均500入力トークンのチャンクが10,000件ある計画用コーパスなら、合計は500万トークンです。このコーパス全体をlargeへ移す際は、2行の価格差だけでなく、largeによる再構築全体の予算が必要です。smallに支払った過去の費用でlargeのベクトルを購入済みになるわけではありません。この時点のOrux料金ではlargeはsmallの約6.5倍ですが、この比率から自社文書の検索改善幅は分かりません。

これらの合計額は、仮定したトークン数を一括計算した見積もりです。実際のチャンク長、クエリ量、再試行、課金の丸め単位により支出は変わります。本番処理を承認する前に、適切なトークナイザーでコーパスのトークン数を測定してください。実行コードは料金計算に整数の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でした。拒否したのは、モデル設定の不一致、ベクトル長の不一致、ゼロベクトル、非有限値の座標、真偽値の座標、前処理バージョンの変更という6つのケースです。smallとlargeの計画額は7.6668 Creditsと49.8336 Creditsで、表と一致します。

これは互換性ルールと料金計算を実行して確認するデモであり、データベースアダプターではありません。埋め込みの取得、近似最近傍インデックスの構築、モデルの検索品質の証明、本番のアクセス制御は実装していません。合成した座標値は小さいため、本番の数値ライブラリでは受け付ける入力値の全範囲にも対応する必要があります。

エンコーダーとインデックスを2組に分けて保持し、整合性のある切り替えとロールバックを可能にする構成。
エンコーダーとインデックスを2組に分けて保持し、整合性のある切り替えとロールバックを可能にする構成。

導入計画では、各クエリエンコーダーを対応するインデックスに接続したまま、組として切り替え、組としてロールバックします。この図は提案する構成であり、導入済みの移行環境ではありません。

クエリエンコーダーとインデックスを1つの設定として切り替える

移行先の仕様に合わせて、新しいコレクションまたは名前空間を作ります。元のソーステキストを読み、記録済みのチャンク分割・前処理ルールを適用して、安定した文書/チャンク識別子の下に移行先モデルのベクトルを保存します。既存コレクションは変更しません。処理済み行数という1つのカウンターだけに頼らず、予定チャンクID、完了ID、ソースのリビジョンを追跡してください。

再構築中にソース文書が変わる場合は、明確な整合性方針を決めます。メンテナンス時間帯のスナップショットを使う単純な方法なら書き込みの競合を避けられますが、更新の反映は遅れます。オンラインで再構築する場合は、切り替え前に編集と削除を新インデックスへ反映するため、再生可能な変更ログ、または同等の照合処理が必要です。削除された1文書と欠落した1文書が件数上たまたま相殺されることもあるため、ベクトル本数が同じだけでは不十分です。

次に、各クエリエンコーダーを対象インデックスと結び付けます。smallのコレクションを読んでいる最中に、アプリケーションだけをlargeのクエリ生成へ変更してはいけません。前処理、次元数、距離指標も含めて、組全体をバージョン管理されたアプリケーション設定として扱います。保存済みのコレクション定義とクエリ設定が一致しない場合は、リクエストを拒否します。複数テナントを扱うシステムでは、両コレクションで同じテナント絞り込みと文書アクセス権限を保ってください。

ロールバックに備え、旧クエリ設定と旧コレクションを一緒に保持します。互換性のある旧クエリをアプリケーションが生成できなくなっていれば、旧ベクトルだけ残しても十分ではありません。切り替え後は、検索品質、文書欠落の報告、レイテンシー、クエリ費用を観察します。新コレクションへの書き込みが成功しただけで旧構成を廃止せず、合意した評価期間と保持期間を終えてから旧ペアを廃止してください。

ベクトルの大きさではなく、正解を付けた検索質問で判断する

全件を再構築する前に、少数でも代表性のある評価セットを用意します。製品ヘルプなら、正確な型番、曖昧なトラブル相談、多言語の問い合わせ、閲覧制限のある文書を含めます。各質問に関連する文書IDを記録してください。生成された回答が流暢に見えるかどうかと、検索結果の関連性の判断は分けます。

各候補設定について、同じ文書スナップショットから同じ件数の結果を取得します。上位結果に関連文書が入るかを比較し、重要な取りこぼしを確認します。短い本文断片から作ったsmallのインデックスと、タイトルと本文を追加したテキストから作ったlargeのインデックスを比較して、差のすべてをモデルの違いに帰してはいけません。チャンク分割、前処理、アクセス権による絞り込みは、それぞれ独立した実験変数です。

この記事は、実測に基づく勝者を示していません。smallの評価結果が要件を満たすなら、現在の入力料金とストレージ容量を抑えられるsmallを選べます。largeを検討するのは、自社の処理で測定した改善が、再構築、ベクトルの大型化、継続的なクエリ費用に見合う場合です。ローカルの互換性テストに合格したことは安全策の確認であり、高いモデルを購入する根拠ではありません。

FAQ

モデルを変更する際は、すべての文書ベクトルを再生成する必要がありますか?

はい。ここで扱う移行では、検索対象コーパスを移行先モデルの空間で再生成します。元のテキストと安定したチャンクIDを保持し、以後のクエリも同じ移行先設定でエンコードしてください。モデル欄の名前を変えるだけで、既存ベクトルに互換性が生まれることはありません。

1,536次元のベクトルにゼロを足して3,072次元にできますか?

配列長の条件を満たすことはできますが、モデル間の写像は確立されません。この移行の互換性確保策には適していません。簡単な幾何学例が示すとおり、形状が正しくても、空間を混ぜれば誤った文書が返る可能性があります。

OruxではOpenAIネイティブのdimensionsパラメーターを指定できますか?

確認した2つのOruxエイリアスの公開フォームには、その項目がありません。OpenAIネイティブのドキュメントだけでは、Oruxでの動作は確認できません。短縮出力を前提にする前に実際の連携仕様を確認し、同じ設定でインデックスの構築と検索を行ってください。

text-embedding-3-largeなら検索が必ず良くなりますか?

この記事では、自社の処理に対する改善は実証していません。自社で正解を付けた質問と同一のソーススナップショットを使い、両設定を評価してください。次元数や価格の大きさは、関連文書を取得できるかという測定の代わりにはなりません。

Pythonサンプルでは実際に何をテストしましたか?

手作業で定義したベクトルの幾何学、設定仕様の不一致による拒否、6つの不正なテスト入力、特定日時の予算とストレージ容量の計算をテストしました。埋め込みAPIを呼び出しておらず、Orux Creditsも消費していません。モデル出力や稼働中のベクトルデータベースのベンチマークも行っていません。

Orux AI で text-embedding-3-smallのモデルページ を試すモデルページでオンライン試用し、料金と API の例を確認できます。モデルを開くOrux AI で text-embedding-3-largeのモデルページ を試すモデルページでオンライン試用し、料金と API の例を確認できます。モデルを開く

1 つの API でこれらのモデルを利用

チャット・画像・動画・音声モデルに対応した OpenAI 互換 API。Credits で従量課金です。