RAG本番2年半で気づいた「惜しい検索」を消す7つの工夫

LLMに最新データを足しても精度が上がらないのはなぜか。本番で何度も失敗した経験から、検索精度を決める7つの要素とうちの実装順序を全部公開します。

RAGで「なんか惜しい」が消えない理由

先日、チームのRAGシステムが検索精度で頭打ちになってて、思わず過去2年半の実装ノート全部見直したんですよ。もともとは「LLMに最新データを与えれば精度上がるでしょ」くらいの気持ちで始めたんですが、本番で回し始めると「なぜか微妙」の連続だった。

ここに至るまで、うちのチームで試した失敗のパターンってめちゃくちゃ多いんです。検索結果が関連性低い、同じドキュメントばっかり返ってくる、長い文脈では完全に崩壊する……。個人的には「RAGは検索精度が8割決める」って今は本気で思ってる。チューニングなしに「ベクトルDB入れたから完璧」みたいなのは、本当にハマります。

最初は懐疑的だったんですが、2年半運用してようやく見えたパターンがある。それが今日の話です。

検索精度を決める7つの要素——うちが実装した順序

正直、この7つの要素を意識し始めたのは、本番で何度も失敗してからなんですよね。最初は「チャンキング → Embedding → ベクトルDB」くらいしか頭になかった。でも実際は、全部が相互に影響してる。各要素がどう絡み合ってるのか、実装順序も含めて説明していきます。

1. チャンキング戦略——ここで半分は決まる

これは本当に大事です。うちは最初、固定サイズ(500トークン)でバッサリ切ってたんですが、それが地獄でした。ドキュメントの構造完全無視で、文の途中で切れるし、コンテキスト喪失する。検索したら「え、これで関連ある?」みたいなのばっかり。

今は階層的チャンキング使ってます。見出しで区切って、段落単位で管理する感じ。Markdownなら見出しレベル、HTMLならDOM構造を活用。セクションごとに「何について書いてるか」のメタデータも付けるようにした。

# うちが今使ってるチャンキング戦略(簡略版)
def chunk_by_structure(content: str, chunk_size: int = 800, overlap: int = 100):
    """セクション構造を保持したチャンキング"""
    chunks = []
    current_section = ""
    current_heading = ""
    token_count = 0
    
    for line in content.split('\n'):
        # 見出しを検出
        if line.startswith('##'):
            if current_section and token_count > 100:
                chunks.append({
                    'text': current_section.strip(),
                    'heading': current_heading,
                    'tokens': token_count
                })
                current_section = ""
                token_count = 0
            current_heading = line.replace('#', '').strip()
        
        current_section += line + "\n"
        token_count += len(line.split())
        
        # サイズ超えたら切る(段落単位を優先)
        if token_count > chunk_size:
            if current_section.strip():
                chunks.append({
                    'text': current_section.strip(),
                    'heading': current_heading,
                    'tokens': token_count
                })
            current_section = ""
            token_count = 0
    
    return chunks

正直、ここで「サイズいくつ?」って決めるのは本当に運用ごとに違うんですよ。うちのユースケースは客サポート用ドキュメントなので、1チャンク800トークン前後が丁度いい。他のチームが同じサイズで成功するとは限らない。実装して、実際の検索ログ見て、調整する。これの繰り返しなんです。

2. Embeddingモデルの選定——2026年の主流がガラッと変わった

去年はOpenAIのtext-embedding-3-smallとllama-embedしか使ってなかったんですが、2026年は選択肢が増えすぎて困ってます。

用途モデル次元数特徴
日本語ドキュメントmultilingual-e5-large-instruct768マルチタスク対応
長文対応nomic-embed-text768最大8192トークン
リアルタイム優先OpenAI text-embedding-3-small1536高速処理
マルチモーダルbge-visualized1024画像+テキスト対応

マルチモーダル対応するなら、今年から増えたbge-visualizedみたいなのも視野に入る。画像とテキスト混在のドキュメントがある場合ですね。

最初は「精度が高い = 次元数が大きい」って思ってたんですが、そうでもないんです。次元数が大きいと検索は遅くなるし、メモリ使うし、コスト上がる。うちは去年text-embedding-3-large(3072次元)で実装したんですが、精度は3-smallの1536と大差ないのに、クエリレイテンシ2倍になった。結局downgradeしました。

3. ベクトルDB選定と設定——同じDBでも運用で精度変わる

ベクトルDB本番運用1年半の実感は「DBのスペック以上に、フィルタリングとリランキングの設計が大事」ということ。

xychart-beta
    title ベクトルDB 精度検証(うちの実装結果)
    x-axis [Pinecone, Weaviate, Qdrant, Milvus]
    y-axis "検索精度(MAP@10)" 0 --> 0.85
    line [0.76, 0.78, 0.79, 0.77]

ご覧の通り、DBによる精度差はほぼないんです。むしろ、その後の処理が効いてくる。

うちが本番で使ってる構成はQdrant(自社オンプレ対応してて、フィルタリング強い)なんですが、このDB選びより大事だったのはmetadata filterの設計。ドキュメント日付、カテゴリ、信頼度スコアなどでフィルタリングして、「検索結果は絞ってから返す」という方針にしたら、精度ぐっと上がった。

# metadata filterの実装例
query_vector = get_embedding(user_query)

# ベクトル検索 + フィルタリング
results = qdrant_client.search(
    collection_name="documents",
    query_vector=query_vector,
    query_filter=models.Filter(
        must=[
            models.HasIdCondition(
                has_id=False  # 削除済みドキュメント除外
            ),
            models.FieldCondition(
                key="confidence_score",
                range=models.Range(gte=0.7)  # 信頼度70%以上
            ),
            models.FieldCondition(
                key="created_date",
                range=models.DatetimeRange(
                    gte=datetime(2025, 1, 1)  # 1年以内
                )
            )
        ]
    ),
    limit=100  # リランキング前に多めに取得
)

このフィルタリングと、その後のリランキング(次の項目)を組み合わせるだけで、ノイズが激減します。

4. リランキング——検索結果の順序を整え直す

ベクトル類似度だけで並べると、本当にダメなんですよ。うちが2026年で実装してるのはms-marco-MiniLM-L12-v2みたいな軽量リランカーと、LLMベースの再評価です。

実際の流れはこんな感じ:

  1. ベクトル検索: 100件取得(精度より広さ優先)
  2. 軽量リランキング: 50件まで絞る
  3. キャッシュ確認: 頻出クエリは結果をRedisに保存
  4. LLM再評価(必要な場合): 重要なクエリはGPT-4oで「本当に関連あるか」確認

LLMの再評価はコスト高いんで、すべてにやってません。ユースケース次第ですが、うちは「サポート案件の解決策提示」みたいな重要度高いクエリだけ。

from sentence_transformers import CrossEncoder

reranker = CrossEncoder('ms-marco-MiniLM-L12-v2')

# ベクトル検索結果をリランキング
documents = vector_search_results
pairs = [[user_query, doc['text']] for doc in documents]

scores = reranker.predict(pairs)

# スコアでソート
reranked = sorted(
    zip(documents, scores),
    key=lambda x: x[1],
    reverse=True
)

final_results = [doc for doc, score in reranked[:10]]

これだけで精度が5-10%上がることがほとんど。リランキングって本当に過小評価されてると思う。

5. Query Transform——ユーザーの質問を正規化する

ユーザーがどういう質問をするかって、文脈依存なんですよね。短すぎるクエリ、タイポ、言い回しがバラバラ……そのまま検索するとダメなんです。

うちが導入してるのは以下の3つ:

  • Query Expansion: 1つの質問から複数の検索クエリに展開
  • Query Compression: 長い会話履歴から核となるクエリ部分を抽出
  • Rewrite: LLMで「このクエリは本当はこういう意図だろう」と言い換え
# Query Expansionの実装
def expand_query(user_query: str, llm_client) -> list[str]:
    """1つのクエリから複数の検索キーワードを生成"""
    expansion_prompt = f"""
    ユーザーのクエリ: {user_query}
    
    このクエリから、以下の3つの異なる検索キーワードを生成してください。
    元のクエリを保持しつつ、同義語や関連表現を使ってください。
    JSON形式で返してください:
    {{"queries": ["query1", "query2", "query3"]}}
    """
    
    response = llm_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": expansion_prompt}],
        temperature=0.5
    )
    
    import json
    try:
        result = json.loads(response.choices[0].message.content)
        return [user_query] + result['queries']
    except:
        return [user_query]

# 複数クエリで並列検索
queries = expand_query(user_input, llm_client)
all_results = []
for q in queries:
    results = vector_search(q)
    all_results.extend(results)

# 重複排除して、再度リランキング
unique_results = {doc['id']: doc for doc in all_results}.values()
final_results = rerank(user_input, list(unique_results))

この工夫だけで、「検索が引っかからなかった」ケースが20-30%減った。ユーザーの言い回しに依存しなくなるんです。

6. 長文コンテキスト対応——LLMが読める長さに最適化

検索して、その結果をLLMに与えるんですが、この「与え方」が大事。ただ検索結果を並べるだけじゃ、LLMはきちんと読まない。特にGPT-4oくらいの長文対応モデルを使うと、「最後の方のドキュメントばっかり引用する」みたいなバイアスが出たりする。

うちはLost in the Middleという現象を意識して、重要度順に再ソートしてから与えるようにしました。最初と最後に重要な情報を配置する戦略ですね。

# Lost in the Middle対策
def arrange_by_importance(documents: list[dict], user_query: str) -> list[dict]:
    """重要度順に並べて、最初と最後に重要な情報を配置"""
    # 各ドキュメントのクエリとの関連度を再計算
    scores = reranker.predict([[user_query, doc['text']] for doc in documents])
    
    # スコアでソート
    sorted_docs = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
    
    # 最初に高スコア、その次に低スコア、最後にまた高スコアを配置
    top_half = sorted_docs[:len(sorted_docs)//2]
    bottom_half = sorted_docs[len(sorted_docs)//2:]
    bottom_half_reversed = bottom_half[::-1]  # 逆順にして最後を高スコアに
    
    rearranged = top_half + bottom_half_reversed
    return [doc for doc, _ in rearranged]

これ地味ですが効きます。LLMって「見出し」を拾いやすいので、検索結果の見出しに「このドキュメントは○○について」っていったん要約を足すのも有効なんですよ。

7. キャッシング戦略とFeedback Loop——本番で継続改善する仕組み

ここまで技術的な話をしてきたんですが、本番運用で一番大事なのは「同じクエリが何度も来たときの高速化」と「ユーザーフィードバックで精度を上げ続ける仕組み」です。

うちは以下を実装してます:

  • Query Cache(Redis): 同じクエリの結果を7日間キャッシュ
  • Feedback Log: ユーザーが「この検索結果、役に立たなかった」を記録
  • 定期的なEmbedding再学習: 3ヶ月ごとに、フィードバックデータからモデル微調整
# キャッシング + フィードバック
import redis
import hashlib

redis_client = redis.Redis()

def get_search_results(user_query: str, user_id: str):
    # クエリのハッシュをキー
    cache_key = f"search:{hashlib.md5(user_query.encode()).hexdigest()}"
    
    # キャッシュ確認
    cached = redis_client.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # キャッシュなければ検索実行
    results = perform_full_search(user_query)
    
    # 7日間キャッシュ
    redis_client.setex(cache_key, 7*24*3600, json.dumps(results))
    
    return results

def log_feedback(user_id: str, query: str, result_id: str, rating: int):
    """1-5スケールでユーザーの満足度を記録"""
    feedback_entry = {
        'user_id': user_id,
        'query': query,
        'result_id': result_id,
        'rating': rating,
        'timestamp': datetime.now().isoformat()
    }
    # 後で微調整用に集計
    feedback_collection.insert_one(feedback_entry)

このフィードバックループを回すことで、「うちのユースケース特有の質問パターン」に段々適応していく。本番運用3-6ヶ月で、驚くほど精度が安定してきたんですよ。

実装パターン図——全体がどう繋がってるか

graph TB
    subgraph Input["ユーザー入力処理"]
        Q1["Query Transform<br/>Query Expansion"]
        Q2["意図理解"]
    end
    
    subgraph Search["検索層"]
        QE["Embedding<br/>生成"]
        VS["Vector Search<br/>ベクトルDB"]
        MF["Metadata<br/>Filter"]
    end
    
    subgraph Ranking["結果最適化"]
        RR["Reranking<br/>CrossEncoder"]
        LM["LLM再評価<br/>必要な場合"]
        Cache["キャッシュ確認"]
    end
    
    subgraph Output["LLM入力準備"]
        Arrange["Lost in the Middle<br/>対策"]
        Context["コンテキスト<br/>整形"]
    end
    
    subgraph Feedback["継続改善"]
        Log["フィードバック<br/>記録"]
        Retrain["定期的な<br/>モデル更新"]
    end
    
    Q1 --> QE
    Q2 --> MF
    QE --> VS
    VS --> MF
    MF --> Cache
    Cache -->|Hit| RR
    Cache -->|Miss| RR
    RR --> LM
    LM --> Arrange
    Arrange --> Context
    Context --> Log
    Log --> Retrain
    
    style Input fill:#e1f5ff
    style Search fill:#fff3e0
    style Ranking fill:#f3e5f5
    style Output fill:#e8f5e9
    style Feedback fill:#fce4ec

2026年の落とし穴——これは本当に注意

マルチモーダルEmbeddingの過剰期待

画像とテキスト両対応のEmbeddingが流行ってるんですが、うちの経験だと、単一モーダル(テキストのみ)で十分なケースが大半なんですよね。むしろ画像まで処理すると、テキスト検索精度が落ちることもある。画像が本当に検索に必要かどうか、最初に判断すべき。

Embeddingモデルの頻繁な切り替え

新しいモデルが出るたびに試したくなるんですが、本番で何度も切り替えるとユーザー体験が揺れます。一度決めたら3-6ヶ月は運用して、比較データ取ってから変更する。今のEmbeddingでも「微妙」なら、調整するのはEmbeddingモデルじゃなく、チャンキングやリランキングから始めるべき。

検索精度だけで判断

RAGの成功は検索精度だけじゃ測れません。検索は高精度でも、「LLMが誤った解釈をする」こともある。ユーザーの最終的な満足度、実際に使ってくれてるかどうかを見るんです。

チーム導入で気づいたこと

正直、RAGって一見簡単に見えるんですが、本番で「なぜか精度が出ない」をぶち当たるのはほぼ必然なんです。うちのチームは半年ハマりました。その時わかったのは以下のことですね。

  • チャンキング戦略は本当に重要: ドキュメント構造を無視すると精度落ちる
  • 複数の評価手段が必須: 自動メトリクスだけじゃダメ。実際の検索ログ、ユーザーフィードバックを見て初めて改善点が見える
  • 地道な改善が答え: 華やかな技術よりも「フィルタリング」「キャッシング」みたいな地味な工夫が効く
  • 運用体制が半分: 検索結果を改善し続ける仕組みを最初から組み込まないと、数ヶ月後に飽和する

あと皆さんはどうしてます?チャンキングサイズの決め方とか、リランキングって本当に必要?みたいなことで迷ってたら、ぜひ試してみてください。

まとめ

RAGの検索精度は、以下の7つの要素が絡み合って決まるんですよ。すべてが同じウエイトではなく、特にチャンキング・Embedding・リランキングが実装の8割を占めます:

  1. チャンキング戦略(セクション構造を保持)
  2. Embeddingモデル選定(用途に合わせた次元数と言語対応)
  3. ベクトルDB設定(フィルタリングが精度を左右)
  4. リランキング(CrossEncoderで順序を整え直す)
  5. Query Transform(ユーザー質問の言い換え・拡張)
  6. 長文対応(Lost in the Middle対策)
  7. キャッシング + フィードバック(継続改善の仕組み)

2026年時点で新しいEmbeddingモデルがいっぱい出てるけど、モデル選定より「検索結果をどう整えるか」の設計のほうが効くんです。本番に入れたら、ユーザーフィードバックを集める仕組みを最初から用意する。これが一番大事。

次のアクション:いま本番RAGで「微妙」なら、Embeddingモデルを変える前に、チャンキング戦略とリランキング層を見直してみてください。うちの経験では、90%のケースはそこで解決しますよ。

U

Untanbaby

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

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

関連記事