Article

Embedding検索の精度を95%まで上げた、誰も教えてくれない10のテクニック

高田晃太郎
Embedding検索の精度を95%まで上げた、誰も教えてくれない10のテクニック

一般的な環境で、標準的なEmbedding検索のPrecision@5はおおむね60〜80%に収まることが多い一方で、実装と運用を積み上げることで95%近傍を現実的に狙えるケースがある、と考えています。 評価は対象業務やデータ分布、指標定義に強く依存しますが、経験的には「良いモデルを選ぶ」だけでは頭打ちになりやすく、医学の診断と同じくデータ収集と手順設計の寄与が大きい。実務のWebスタックに落とし込んだときに効くのは、データの分割と正規化、ハイブリッド検索、再ランキング、クエリ書き換え、ANNチューニング、継続的評価の6本柱です。以下では、現場にそのまま持ち帰れる実装レベルのテクニックを、コードと指標、運用目線で解説します。

精度95%の前提条件を固める:評価軸とゴールドの作り方

まず「95%」の意味を定義します。ここでは業務QAとドキュメント検索を対象に、トップ5に少なくとも1件の正解が含まれるかを測るPrecision@5を主軸に置き、併せてnDCG@10とクリック率のオフライン代替指標(加重スコア)を用いる前提を推奨します。² ゴールドデータは領域ごとに100問以上を複数人(2名以上)でラベル付けし、合意率0.8前後を目安に担保し、ラベル粒度は厳密一致と関連許容の二層に分けると、改善効果の判定が安定します。実務ではこの評価基盤が曖昧だと改善サイクルが回らず、以降のテクニックの効果検証ができません。コード化した評価器をCIに組み込み、PR単位で精度回帰を検知できるようにすると、Web開発のスプリントでも回しやすくなります。

# evaluator.py
from typing import List, Dict

def precision_at_k(results: Dict[str, List[str]], gold: Dict[str, List[str]], k: int = 5) -> float:
    hits = 0
    total = 0
    for q, retrieved in results.items():
        total += 1
        g = set(gold.get(q, []))
        topk = retrieved[:k]
        if any(doc_id in g for doc_id in topk):
            hits += 1
    return hits / total if total else 0.0

if __name__ == "__main__":
    try:
        mock_results = {"q1": ["d3","d1","d2"], "q2": ["d4"]}
        mock_gold = {"q1": ["d1"], "q2": ["d7"]}
        print(f"P@5={precision_at_k(mock_results, mock_gold, 5):.3f}")
    except Exception as e:
        import sys
        print(f"evaluation error: {e}", file=sys.stderr)
        sys.exit(1)

この評価器でベースラインを固定し、以降の変更はすべてA/Bで差分計測します。多くの現場では、まずハイブリッド化と再ランキング導入でPrecisionの大幅改善が見込め、残りの数ポイントをクエリ書き換えとANNパラメータ最適化で詰める、という順序が堅実です。

データ設計と表現の最適化:半分は前処理で決まる

チャンク設計は文境界+スライドで重複最小化

Embeddingの精度を押し上げる最初の工夫は、文単位の分割に固定長のスライディングウィンドウを重ねる方式です。³ ドキュメントの章や箇条書きが長い場合でも、文境界を守りつつ前後文脈を30〜40%重ねると、クエリと断片的に一致する確率が上がり、特に非構造テキストでリコールが向上します。状況によってはPrecision@5が数ポイント伸びることが期待できます。³

# chunker.py
import re
from typing import List

SENT_SPLIT = re.compile(r"(?<=[。..!?])\s+")

def sent_chunk(text: str, max_tokens: int = 512, overlap: int = 192, tokenize=lambda x: x.split()) -> List[str]:
    sents = [s.strip() for s in SENT_SPLIT.split(text) if s.strip()]
    chunks, cur, cur_tokens = [], [], 0
    for s in sents:
        t = tokenize(s)
        if cur_tokens + len(t) <= max_tokens:
            cur.append(s); cur_tokens += len(t)
        else:
            chunks.append(" ".join(cur))
            # overlap from tail
            tail = tokenize(" ".join(cur))[-overlap:] if overlap > 0 else []
            cur = [" ".join(tail), s] if tail else [s]
            cur_tokens = len(tail) + len(t)
    if cur:
        chunks.append(" ".join(cur))
    return [c.strip() for c in chunks if c.strip()]

生成したチャンクは正規化を施します。半角全角や不可視文字、重複空白、テンプレートのフッターなど、再現性高く除去できるノイズを落とすだけで余計な近傍が減り、ANNの分岐が安定します。さらにSimHashでの近似重複除去を前段に入れると、インデックスサイズを一桁台〜十数%抑えつつ、検索速度とメモリの両方に効くケースが多い。⁴

メタデータ設計はフィルタ前提で逆算する

業務ユースでは、部門、機密区分、言語、施行日などの属性でフィルタが必須です。Embeddingだけに頼らず、メタデータで候補集合を縮めてから類似検索に入ると、低遅延での安定稼働に寄与し、精度面でも狙い撃ちがしやすくなります。ハイブリッド検索や段階的なフィルタリングは低遅延運用で有効であることが報告されています。⁵

モデル選定と拡張:多言語・ドメイン適応・クエリ書き換え

モデルはサイズと次元で運用コストを含めて決める

公開モデルやAPIの候補は多いですが、次元数がメモリ・帯域・遅延に直結します。⁶ 例えば3072次元の高精度モデルからPCAで768次元へ圧縮し、再学習したコサイン類似空間に載せ替えると、わずかな精度低下と引き換えにインデックス常駐メモリを約75%削減できます。高トラフィックのWebサービスでは、この削減が月次コストに直結します。

# reduce_dim.py
import numpy as np
from sklearn.decomposition import PCA

def fit_pca(embs: np.ndarray, out_dim: int = 768) -> PCA:
    pca = PCA(n_components=out_dim, svd_solver='auto', random_state=42)
    pca.fit(embs)
    return pca

def transform(pca: PCA, embs: np.ndarray) -> np.ndarray:
    return pca.transform(embs)

多言語対応では、クエリとコーパスの言語が一致しないケースが落とし穴です。多言語モデルを使うか、前段で言語検出を行い、必要に応じて翻訳してから埋め込みます。ここは遅延コストと精度のトレードオフで、英語優先の領域は英語へ正規化、法務や医療など精度重視は多言語モデルに寄せる、といった切り分けが有効です。⁷

ドメイン適応のファインチューニングで最後の数ポイントを獲る

一般モデルのままでは専門用語の意味距離が適切に学習されていません。クリックログやFAQからクエリ・ポジティブ・ハードネガティブのトリプレットを作り、対照学習で微調整すると、PrecisionやnDCGが数ポイント伸びることがよく報告されます。⁸ 以下はSentence-Transformersを用いた実装例です。

# finetune.py
from sentence_transformers import SentenceTransformer, losses, InputExample
from torch.utils.data import DataLoader

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
train_examples = [InputExample(texts=["sso error 403", "IdP session expired" , "db timeout"])]
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=64)
train_loss = losses.TripletLoss(model)

try:
    model.fit(train_objectives=[(train_dataloader, train_loss)],
              epochs=2, warmup_steps=100, show_progress_bar=True)
    model.save("./models/domain-miniLM")
except Exception as e:
    print(f"finetune failed: {e}")

クエリ書き換えと拡張で意図を言語化する

ユーザのクエリは短く曖昧です。LLMで3案ほどの拡張クエリを生成し、それぞれで候補を取得して統合し、再ランキングに回すと、曖昧語や社内略語での取りこぼしが減ります。⁹ 実運用でもPrecisionやクリック率の改善が観測されやすく、特にRAGやヘルプデスク文脈で効きます。

# query_rewrite.py
import os, json, time
from typing import List
from openai import OpenAI

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

PROMPT = """ユーザー質問を検索向けに3つの言い換えで出力。
- 固有名詞を保持
- 同義語と用途を含める
- 20語以内
"""

def expand(query: str) -> List[str]:
    try:
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role":"system","content":PROMPT},
                     {"role":"user","content":query}],
            temperature=0.2
        )
        text = resp.choices[0].message.content
        cands = [c.strip("- ・\n ") for c in text.split("\n") if c.strip()]
        return cands[:3] if cands else [query]
    except Exception as e:
        time.sleep(0.5)
        return [query]

検索パイプラインの最適化:ハイブリッド、ANN、再ランキング

BM25+ベクトルのハイブリッドは重みを学習で決める

BM25は語形と希少語に強く、Embeddingは意味に強い。両者をスコア正規化して線形結合し、重みを学習で決めるのが実務で強力です。PostgreSQL+pgvectorでも実現できます。以下の例ではテキスト検索で上位100を絞り、コサイン類似度と結合する構成です。⁵

-- hybrid.sql
WITH kw AS (
  SELECT id, ts_rank_cd(to_tsvector('simple', body), plainto_tsquery('simple', :q)) AS bm25
  FROM docs
  WHERE to_tsvector('simple', body) @@ plainto_tsquery('simple', :q)
  ORDER BY bm25 DESC
  LIMIT 100
)
SELECT d.id,
       (0.45 * (kw.bm25 / NULLIF(max(kw.bm25) OVER (), 0)) +
        0.55 * (1 - (d.embedding <=> :q_emb))) AS score
FROM kw JOIN docs d USING(id)
ORDER BY score DESC
LIMIT 20;

重み0.45/0.55は一例です。学習データの正解が最上位に来るよう、ベイズ最適化などで探索すると、データセット変更時の再調整も自動化できます。ハイブリッド化だけで二桁%規模の改善が狙えることもあります。

ANNインデックスはHNSW/IVFのパラメータが命

HNSWではMとefSearch、IVFではnlistとnprobeが再現率と遅延を決めます。¹⁰ 例えばHNSWで探索幅(efSearch)を広げると再現率は上がりますが、p95レイテンシは悪化します。ベクトル数や次元、ハードウェアに応じて、所要SLOに合うトレードオフ点を見つけてください。近年のベンチマークでもHNSWの有効性は繰り返し報告されています。¹¹ Qdrantでの実行例を示します。

# qdrant_search.py
from qdrant_client import QdrantClient
from qdrant_client.http.models import SearchParams

client = QdrantClient(host="localhost", port=6333)

params = SearchParams(hnsw_ef=128, quantization=False)

try:
    hits = client.search(collection_name="docs", query_vector=q_emb, limit=20, search_params=params)
    for h in hits:
        pass
except Exception as e:
    print(f"qdrant search error: {e}")

Cross-Encoderでの再ランキングは少量でも効く

上位50件を双方向エンコーダで再採点すると、意味の粒度が合わない誤ヒットを弾けます。モデルは軽量なMiniLM系でも効果が大きく、PrecisionやnDCGの押し上げに効きます。さらにBERT系の再ランキングはMS MARCOなどのベンチマークで大幅な性能改善が確認されています。¹³ また、トップK候補のみを再ランキングする設計は、精度とレイテンシのバランスを取りやすいことが示されています。¹⁴

# rerank.py
from sentence_transformers import CrossEncoder

reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def rerank(query: str, candidates: list[str]) -> list[tuple[str, float]]:
    pairs = [[query, c] for c in candidates]
    try:
        scores = reranker.predict(pairs)
        ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
        return ranked
    except Exception as e:
        return list(zip(candidates, [0.0]*len(candidates)))

運用で磨く:ハードネガティブ、観測、フェイルセーフ

ハードネガティブ採掘で学習データを更新し続ける

ログから「クリックされなかったが上位表示された」ペアを収集し、ポジティブに近いネガティブとして再学習に回すと、モデルは曖昧な境界を学びます。FAISSのIVF-PQで大規模に近傍探索し、難例を効率的に拾う実装は次の通りです。¹²

# mine_hard_negatives.py
import faiss, numpy as np

embs = np.load("embeddings.npy").astype('float32')
index = faiss.index_factory(embs.shape[1], "IVF4096,PQ32")
index.train(embs)
index.add(embs)
index.nprobe = 16
D, I = index.search(embs[:1000], 50)
# I[i,1:] の上位は擬似ネガ候補。クリックログでフィルタして採用

オフライン評価と合わせて、サーバサイドではクエリ属性別のヒット率、空振り率、再ランキングの昇格率などをメトリクス化し、しきい値超えでアラートします。Web開発の監視基盤にPrometheusを使うなら、メトリクスをエンドポイントで公開しておくと、回帰検知が容易です。

キャッシュとフォールバックでSLOを守る

高頻度クエリのEmbeddingと上位結果はTTL付きでキャッシュし、ANNでタイムアウトが起きた場合はBM25単独にフォールバックする二段構えにします。これでp99のスパイクを吸収し、ユーザ体験を滑らかに保てます。Node.jsのAPI層での実装例です。

// api.js
import express from 'express';
import LRU from 'lru-cache';

const app = express();
const cache = new LRU({ max: 5000, ttl: 60_000 });

app.get('/search', async (req, res) => {
  const q = String(req.query.q || '').trim();
  const key = `s:${q}`;
  const hit = cache.get(key);
  if (hit) return res.json(hit);
  try {
    const ann = await annSearch(q, { timeoutMs: 120 });
    cache.set(key, ann);
    res.json(ann);
  } catch (e) {
    const kw = await bm25Fallback(q);
    res.json(kw);
  }
});

app.listen(3000);

これらの運用テクニックを回しながら、関連ドキュメントを充実させるのも精度に効きます。再現性の高い実装手順は、例えばハイブリッド検索の基礎、pgvectorチューニング、RAG評価設計といったトピックをチームのナレッジとして整備しておくと、再利用性が上がり、オンボーディングも速くなります。

まとめ:10のテクニックを2週間スプリントで回す

ここまでの要点を実務に落とし込むなら、まず評価基盤を固定し、チャンク設計と正規化でコーパス品質を上げ、ハイブリッド検索と再ランキングで土台の精度を底上げします。次に、クエリ書き換えとANNパラメータ最適化で取りこぼしを減らし、ドメイン適応とハードネガティブ採掘で最後の数ポイントを積み上げます。運用ではキャッシュとフォールバックでSLOを守りつつ、メトリクスで回帰を監視します。これら10のテクニックはそれぞれ単体でも効きますが、組み合わせることでPrecision@5の95%近傍を狙える現実的なロードマップになります。あなたのプロダクトに合わせ、どこから手を付けるのが最もROIが高いかを見極めるところから始めてみませんか。2週間のスプリントで、評価器の導入とハイブリッド+再ランキングまで到達すれば、精度の伸びとレイテンシのバランスが見えるはずです。気づきがあれば、次はドメイン適応に一歩踏み出してみてください。

参考文献

  1. Muennighoff, N., et al. (2023). MTEB: Massive Text Embedding Benchmark. arXiv:2210.07316. https://arxiv.org/abs/2210.07316
  2. Manning, C. D., Raghavan, P., Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press. https://nlp.stanford.edu/IR-book/
  3. Context-aware chunking for retrieval-augmented generation(周辺チャンクの文脈活用に関する報告). arXiv:2409.04701v1. https://arxiv.org/html/2409.04701v1
  4. Charikar, M. (2002). Similarity estimation techniques from rounding algorithms. STOC. https://dl.acm.org/doi/10.1145/509907.509965
  5. Hybrid Search: lexical+semanticの併用に関する総説とベンチマーク(低遅延での性能向上に関する記述あり). arXiv:2407.01219v1. https://arxiv.org/html/2407.01219v1
  6. Jégou, H., Douze, M., Schmid, C. (2011). Product Quantization for Nearest Neighbor Search. IEEE TPAMI. https://ieeexplore.ieee.org/document/5432202
  7. Feng, F., Yang, Y., Cer, D., et al. (2020). Language-agnostic BERT Sentence Embedding (LaBSE). arXiv:2007.01852. https://arxiv.org/abs/2007.01852
  8. Reimers, N., Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. arXiv:1908.10084. https://arxiv.org/abs/1908.10084
  9. Carpineto, C., Romano, G. (2012). A Survey of Automatic Query Expansion in Information Retrieval. ACM Computing Surveys. https://dl.acm.org/doi/10.1145/2071389.2071390
  10. Malkov, Y. A., Yashunin, D. A. (2018). Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. IEEE TPAMI. https://ieeexplore.ieee.org/document/8594636
  11. ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms. https://ann-benchmarks.com
  12. Johnson, J., Douze, M., Jégou, H. (2017). Billion-scale similarity search with GPUs(FAISS). arXiv:1702.08734. https://arxiv.org/abs/1702.08734
  13. Nogueira, R., Cho, K. (2019). Passage Re-ranking with BERT. arXiv:1901.04085. https://arxiv.org/abs/1901.04085
  14. Latency–accuracy trade-offs in re-ranking pipelines(トップK再ランキングの効率性に関する実験報告). arXiv:2410.12890. https://arxiv.org/html/2410.12890v1