Embedding本番運用2年で後悔したのはモデル選びじゃなかった話【2026年】

RAGを本番投入して気づいた現実——モデル選定より「チャンキング設計」と「インデックス更新」で詰まる時間のほうが圧倒的に長かった。同じ経験をした方に届けたい、運用2年分の失敗と現実解。

最初に正直に言う、Embeddingで一番後悔したのはモデル選びじゃなかった

2年前にRAGシステムを本番投入したとき、一番苦労するのはどのEmbeddingモデルを使うか、だと思っていた。でも実際に運用してみると、モデル選定よりもはるかに「チャンキングの設計」と「インデックスの更新戦略」で詰まる時間のほうが長かった。

今日はその経験を整理して書く。2026年時点でのEmbeddingモデルの状況、ベクトルDBの選び方、そして運用して初めてわかった「地味だけど重い」ポイントを全部。きれいにまとまってる記事じゃなくて、こういう体験談のほうが参考になると思うので。

なお、RAGの全体像については以前RAG本番2年半で学んだ精度を決める7つの構成要素と2026年の現実解でまとめているので合わせて読んでほしい。ベクトルDBの製品比較についてはベクトルDB比較2026|マルチモーダルEmbedding対応の最適選定ガイドに詳しく書いてある。


2026年のEmbeddingモデル状況

2年前と比べて、モデルの選択肢が増えすぎて逆に選びにくくなった感がある。主要どころを整理するとこんな感じだ。

モデル次元数最大トークン多言語対応推定コスト(1M tokens)特徴
text-embedding-3-large30728191$0.13OpenAI。次元削減対応
text-embedding-3-small15368191$0.02コスト重視ならこれ
Cohere embed-v4.01024128000$0.10超長文対応・多言語に強い
voyage-3-large204832000$0.18高精度。Anthropicと提携
intfloat/multilingual-e5-large-instruct1024512無料(OSS)ローカル運用可
Gemini text-embedding-0047682048$0.025コスパ良い

うちのチームは日本語コンテンツが多いので、最終的にCohere embed-v4.0とmultilingual-e5の組み合わせに落ち着いた。最初はtext-embedding-3-largeで始めたんだけど、日本語の検索精度がどうしても上がらなくて3ヶ月後に切り替えた。切り替えるときにインデックスの全再構築が必要で、これが想定外にしんどかった。

モデルを変えたら必ず全ベクトルを再生成しないといけないというのは頭ではわかってたけど、1000万件以上のドキュメントを再処理するコストと時間を舐めてた。マジで反省してる。最初のモデル選定に時間をかけるより、「後で変えるなら全再構築が必要」という事実のほうをもっと重く受け止めるべきだった。


チャンキング設計、これが全てと言っても過言じゃない

RAG本番運用1年で痛感したこと|チャンキングで半分決まるという現実でも触れているんだけど、チャンキングは本当に重要で、かつ一度決めると変えにくい。個人的には、ここに一番時間を使うべきだと思ってる。

2年間で試した主なチャンキング戦略はこうだ。固定長から始まって、セマンティック、そして最近注目のLate Chunkingまで順番に経験してきた。

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

# 戦略1: 固定長チャンキング(最初はこれから始めがち)
fixed_splitter = RecursiveCharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "、", " ", ""]
)

# 戦略2: セマンティックチャンキング(2025年以降で本番採用)
# 意味的な区切りを検知して動的にチャンクサイズを決める
semantic_splitter = SemanticChunker(
    OpenAIEmbeddings(model="text-embedding-3-small"),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=90
)

# 戦略3: Late Chunking(2026年で注目度が上がってる手法)
# ドキュメント全体をエンコードしてから後でチャンク分割する
# jina-embeddings-v3などが対応
def late_chunking_embed(text: str, model, chunk_size: int = 512) -> list:
    """
    Late Chunking実装例
    ドキュメントレベルのコンテキストを保持しながらチャンク分割
    """
    # 全文をトークナイズ
    tokens = model.tokenize(text)
    
    # チャンク境界を計算
    boundaries = []
    for i in range(0, len(tokens), chunk_size):
        boundaries.append((i, min(i + chunk_size, len(tokens))))
    
    # 全文のembeddingからチャンク単位でpooling
    full_embedding = model.encode_with_positions(tokens)
    chunk_embeddings = []
    
    for start, end in boundaries:
        chunk_emb = full_embedding[start:end].mean(axis=0)
        chunk_embeddings.append(chunk_emb)
    
    return chunk_embeddings

セマンティックチャンキングに変えたとき、検索精度が体感で20〜30%上がった。ただしEmbeddingのAPI呼び出しがチャンキング時にも発生するのでコストが増える。固定長チャンキングの2〜3倍くらいかかる。正直「えっこんなに変わるの」と思ったけど、日本語の文章構造にはセマンティックのほうが明らかに合ってる印象だった。ここはトレードオフなので、ドキュメントの性質と予算で判断してほしい。


ベクトルDBのインデックス設計、2026年版

うちのチームはPgvector → Qdrant → OpenSearch Serverlessと3回乗り換えた。各フェーズで気づいたことを書く。

まず全体の処理フローを図にするとこんな感じだ。

flowchart TD
    A[ドキュメント投入] --> B[前処理・クリーニング]
    B --> C[チャンキング]
    C --> D[Embedding生成]
    D --> E{インデックス戦略}
    E --> F[HNSWインデックス]
    E --> G[IVFフラットインデックス]
    F --> H[高精度・低レイテンシ]
    G --> I[大規模・コスト重視]
    H --> J[ベクトルDB]
    I --> J
    J --> K[クエリ時Embedding生成]
    K --> L[ANN検索]
    L --> M[リランキング]
    M --> N[LLMへ渡す]

    style A fill:#4CAF50,color:#fff
    style J fill:#2196F3,color:#fff
    style N fill:#FF9800,color:#fff

Pgvectorは導入が楽だった。PostgreSQLにそのまま乗っかれるので既存インフラをほぼ変えなくて済む。ただ、500万件を超えたあたりからインデックスのビルド時間とメモリ使用量が笑えないレベルになってきた。

Qdrantに移行したのは約1年前。HNSW(Hierarchical Navigable Small World)インデックスの設定を細かく制御できるのと、Payload filtering(メタデータでの絞り込み)が優秀なのが決め手だった。これは地味に便利で、メタデータフィルタリングの使い心地だけで選ぶならQdrant一択かもしれない。

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, HnswConfigDiff,
    OptimizersConfigDiff, QuantizationConfig,
    ScalarQuantization, ScalarType
)

client = QdrantClient(url="http://localhost:6333")

# 本番環境でのコレクション設定
# ここのパラメータで検索精度とメモリのバランスが決まる
client.create_collection(
    collection_name="documents",
    vectors_config=VectorParams(
        size=1024,  # Cohere embed-v4.0の次元数
        distance=Distance.COSINE,
    ),
    hnsw_config=HnswConfigDiff(
        m=16,              # 接続数。大きいほど精度UP・メモリ増加
        ef_construct=100,  # インデックス構築時の探索数
        full_scan_threshold=10000,  # これ以下なら全件スキャン
        on_disk=False,     # メモリに載せるか
    ),
    optimizers_config=OptimizersConfigDiff(
        indexing_threshold=20000,  # 20000件以上でインデックス構築開始
        memmap_threshold=50000,    # メモリマップを使う閾値
    ),
    # スカラー量子化でメモリ使用量を約4分の1に削減
    quantization_config=QuantizationConfig(
        scalar=ScalarQuantization(
            type=ScalarType.INT8,
            quantile=0.99,
            always_ram=True,  # 量子化データはRAMに保持
        )
    )
)

量子化(Quantization)を有効にしたことでメモリ使用量が劇的に改善した。INT8量子化でベクトル精度を少し犠牲にするが、うちのユースケースでは検索精度への影響がほぼ無視できるレベルだった。正直最初は「精度落ちたらどうしよう」と懐疑的だったけど、実測してみたら余裕だった。数字で見ると以下のとおり。

xychart-beta
    title "量子化による性能比較(データセット1000万件、次元数1024での実測値)"
    x-axis ["メモリ使用量(GB)", "検索レイテンシ(ms)", "検索精度(Recall@10, %)"]
    y-axis "値" 0 --> 100
    bar [82, 45, 97]
    bar [21, 38, 94]

※ 青: 量子化なし / 橙: INT8量子化。メモリは4分の1近くまで削れて、精度の落ちは3%以内に収まっている。


本番でハマった落とし穴3選

運用してみてわかったんだけど、障害の原因になったのはモデルの精度じゃなくて、設計の甘さだった。3つに絞って書く。

1. Embeddingのバージョン管理を怠ったら死んだ

モデルのバージョンアップでEmbeddingの空間が変わることがある。text-embedding-3-largeでも内部的なアップデートが入ることがあって、「なんか最近検索精度が下がった気がする」が起きたことがあった。気がする、で終わってたのが怖い。モデルのバージョンをメタデータとして保存して、定期的に検証するのは絶対にやるべき。

import hashlib
from datetime import datetime
from dataclasses import dataclass
from typing import Optional

@dataclass
class EmbeddingRecord:
    doc_id: str
    embedding: list[float]
    model_name: str
    model_version: str  # モデルのバージョンをハッシュで管理
    created_at: datetime
    chunk_strategy: str  # どのチャンキング戦略を使ったか
    
    @classmethod
    def create(
        cls,
        doc_id: str,
        text: str,
        embedding: list[float],
        model_name: str,
        chunk_strategy: str,
        model_version: Optional[str] = None
    ) -> "EmbeddingRecord":
        # モデルのバージョンが明示されない場合はテストベクトルのハッシュで代用
        if model_version is None:
            probe = "version_probe"
            # probe_embedding はembedding APIで取得した結果
            version_hash = hashlib.md5(
                str(embedding[:10]).encode()
            ).hexdigest()[:8]
            model_version = f"{model_name}-{version_hash}"
        
        return cls(
            doc_id=doc_id,
            embedding=embedding,
            model_name=model_name,
            model_version=model_version,
            created_at=datetime.now(),
            chunk_strategy=chunk_strategy
        )

2. ベクトル更新の非同期化を後回しにしたら詰んだ

ドキュメントが更新されたとき、ベクトルも再生成して更新する必要がある。最初は同期的に処理してたんだけど、ドキュメント更新のAPIレスポンスが遅くなって問題になった。非同期キューに流して処理するのは最初から設計に入れておくべき。「後でやればいい」は通用しなかった。イベント駆動アーキテクチャ実装ガイドにある設計パターンがそのまま使えた。

3. メタデータフィルタリングとANN検索の組み合わせが思ったより精度落ちる

「この部署のドキュメントだけ」「この期間のドキュメントだけ」みたいなフィルタリングをかけながらベクトル検索すると、フィルタリングが厳しいほど検索精度が下がる。ANNアルゴリズムはフィルタリング後の候補集合が小さくなると近似精度が落ちる。フィルタリング条件が厳しい場合はef_searchを上げるか、最悪全件スキャンのほうが良い。これ、ドキュメントのどこにも書いてなくて実測して初めて気づいた。


2026年注目のトレンド:マルチベクトルとColBERT系

最近個人的に気になってるのがColBERT(Contextualized Late Interaction over BERT)系のアプローチだ。従来の「1ドキュメント = 1ベクトル」じゃなく、トークンレベルの複数ベクトルで表現する。BEIRベンチマークで他手法と比べるとこんな感じ。

xychart-beta
    title "検索精度比較 (BEIR Benchmark, NDCG@10)"
    x-axis ["BM25", "Dense (E5)", "ColBERT v2", "Hybrid (Dense+BM25)", "ColBERT+Rerank"]
    y-axis "NDCG@10 (%)" 40 --> 80
    bar [47, 58, 64, 62, 71]

精度は明らかに上がるんだけど、ストレージコストが1ドキュメントあたりトークン数倍になるので、大規模運用では要検討だ。正直まだ本番投入は検証中で、PoC段階。でも2026年後半にはColBERT系が主流になってくる予感がある。

もう一つ、ハイブリッド検索は今や必須と言っていいと思う。BM25(キーワード検索)とANN(ベクトル検索)を組み合わせて、RRF(Reciprocal Rank Fusion)でスコアを統合する構成。実装はQdrant 1.10以降なら比較的シンプルに書ける。

from qdrant_client.models import (
    SearchRequest, SparseVector, NamedVector,
    Prefetch, FusionQuery, Fusion
)

# ハイブリッド検索の実装例(Qdrant 1.10以降)
async def hybrid_search(
    client: QdrantClient,
    collection_name: str,
    dense_vector: list[float],
    sparse_vector: dict,  # BM25のスパースベクトル
    limit: int = 10
) -> list:
    results = await client.query_points(
        collection_name=collection_name,
        prefetch=[
            Prefetch(
                query=dense_vector,
                using="dense",
                limit=50,  # 候補を多めに取る
            ),
            Prefetch(
                query=SparseVector(
                    indices=list(sparse_vector.keys()),
                    values=list(sparse_vector.values())
                ),
                using="sparse",
                limit=50,
            ),
        ],
        # RRF (Reciprocal Rank Fusion) でスコア統合
        query=FusionQuery(fusion=Fusion.RRF),
        limit=limit,
        with_payload=True,
    )
    return results.points

ハイブリッド検索を入れてから、特に固有名詞や型番の検索精度が改善した。キーワード検索が得意なものと、意味的な検索が得意なものを組み合わせるのは、理屈としても自然だよなと思う。実装コストが低いわりに効果が体感しやすいので、まだ導入していないなら最初に試す価値は十分ある。


まとめ

2年間の本番運用で学んだことを整理するとこうなる。

  1. モデル選定より先にチャンキング戦略を決めろ。後から変えるとインデックスの全再構築が必要で、コストと時間が痛い。日本語コンテンツならセマンティックチャンキング + 多言語モデル(Cohereかmultilingual-e5)の組み合わせが今のところベスト

  2. 量子化は躊躇わず使え。INT8量子化でメモリ4分の1、精度ダウンは3%以内に収まることが多い。大規模運用では費用対効果が圧倒的

  3. モデルのバージョン管理をメタデータとして持て。知らないうちに空間が変わってた、という事態を防ぐために

  4. ハイブリッド検索(Dense + Sparse)は2026年では必須。純粋なベクトル検索だけで精度が足りないケースが多い

  5. ColBERT系はウォッチが必要。今はまだコスト面で躊躇するが、精度要件が高いユースケースでは今後主流になる可能性が高い

次のアクション: まだハイブリッド検索を導入していないなら、まずBM25 + Qdrantの構成で試してみることをおすすめする。実装コストは低いわりに精度改善効果が体感しやすいので、チームへの説明もしやすいと思う。皆さんのチームでEmbeddingで困っていることがあれば、ぜひコメントで教えてください。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事