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-instruct | 768 | マルチタスク対応 |
| 長文対応 | nomic-embed-text | 768 | 最大8192トークン |
| リアルタイム優先 | OpenAI text-embedding-3-small | 1536 | 高速処理 |
| マルチモーダル | bge-visualized | 1024 | 画像+テキスト対応 |
マルチモーダル対応するなら、今年から増えた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ベースの再評価です。
実際の流れはこんな感じ:
- ベクトル検索: 100件取得(精度より広さ優先)
- 軽量リランキング: 50件まで絞る
- キャッシュ確認: 頻出クエリは結果をRedisに保存
- 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割を占めます:
- チャンキング戦略(セクション構造を保持)
- Embeddingモデル選定(用途に合わせた次元数と言語対応)
- ベクトルDB設定(フィルタリングが精度を左右)
- リランキング(CrossEncoderで順序を整え直す)
- Query Transform(ユーザー質問の言い換え・拡張)
- 長文対応(Lost in the Middle対策)
- キャッシング + フィードバック(継続改善の仕組み)
2026年時点で新しいEmbeddingモデルがいっぱい出てるけど、モデル選定より「検索結果をどう整えるか」の設計のほうが効くんです。本番に入れたら、ユーザーフィードバックを集める仕組みを最初から用意する。これが一番大事。
次のアクション:いま本番RAGで「微妙」なら、Embeddingモデルを変える前に、チャンキング戦略とリランキング層を見直してみてください。うちの経験では、90%のケースはそこで解決しますよ。