RAG本番運用1年で痛感したこと|チャンキングで半分決まるという現実
「ドキュメント入れれば賢くなる」と思ってた時期が私にもありました。月間数万クエリをさばくまでに試行錯誤したチャンキング設計やハイブリッド検索の実践知見をまとめています。
RAGを最初に触ったのは2024年末ごろで、「これでLLMの幻覚が消えるじゃん」と単純に喜んでいた。でも実際にプロダクション投入して1年ほど経つと、思い描いていた「ドキュメント突っ込めば賢くなる」という世界観がいかに甘かったかを痛感している。
うちのチームでは社内ナレッジベースの検索・応答システムにRAGを採用していて、今では月間数万クエリをさばくようになった。その過程で試行錯誤したことを、できるだけ具体的に書き残しておこうと思う。同じ轍を踏んでほしくないし、「最初からこれ知りたかった」という知見がたくさんあるので。
なお、ベクトルDBの選定についてはベクトルDB比較2026|マルチモーダルEmbedding対応の最適選定ガイドで詳しく書いたので、そちらも参考にしてほしい。
チャンキング戦略で半分くらい決まる話
正直、最初はチャンキングを甘く見ていた。「適当に512トークンで分割すればいいでしょ」という感じで始めたら、検索品質がボロボロだった。
RAGの検索精度がなぜ落ちるのかを振り返ると、チャンキングの問題が圧倒的に多い。具体的には以下の3パターンで痛い目を見た。
固定長チャンキングの罠:文章の途中で切れてしまい、1つのチャンクだけでは文脈が完結しない。「AというシステムはBに依存しており、詳細は以下を参照──」みたいな文章が分断されて、検索はヒットするのに回答に肝心の情報が入らない、という最悪なパターン。
オーバーラップで誤魔化すと冗長になる:50〜100トークンのオーバーラップを入れれば多少マシになるが、今度はほぼ同じ内容のチャンクが複数ヒットしてコンテキスト枠を圧迫する。帯に短し襷に長し、という感じ。
セクション構造を無視する問題:MarkdownやWikiはセクション単位で意味が完結することが多いのに、文字数だけで分割すると見出し直後に無関係な内容が続くチャンクができあがる。
これらを踏まえて今のチームで使っているのが、セマンティックチャンキング+階層構造保持の組み合わせだ。
from langchain_text_splitters import MarkdownHeaderTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# Step1: 見出しで大まかに分割
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "H1"),
("##", "H2"),
("###", "H3"),
],
strip_headers=False,
)
md_chunks = md_splitter.split_text(raw_document)
# Step2: 各セクション内をセマンティックに再分割
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
semantic_splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=85,
)
final_chunks = []
for chunk in md_chunks:
sub_chunks = semantic_splitter.split_text(chunk.page_content)
for sub in sub_chunks:
final_chunks.append({
"content": sub,
"metadata": {
**chunk.metadata,
"char_count": len(sub),
"source": chunk.metadata.get("source", "unknown"),
}
})
print(f"Total chunks: {len(final_chunks)}")
# Total chunks: 847
セマンティックチャンキングは埋め込みモデルで文章の意味的な区切りを判定するので、固定長より自然な分割ができる。ただし処理コストが上がるのと、ドキュメントが短すぎると逆効果なので、閾値のチューニングが必要だった。ここは正直まだ最適値を模索中だったりする。
実際に各戦略のRecall@5を測ると、数字の差がくっきり出た。
xychart-beta
title "チャンキング戦略別 Recall@5 比較"
x-axis ["固定512token", "固定+オーバーラップ", "段落分割", "セマンティック", "階層+セマンティック"]
y-axis "Recall@5 (%)" 0 --> 100
bar [52, 61, 68, 74, 83]
階層+セマンティックが一番良いのはそうなんだけど、インジェスト時間が2〜3倍かかるのがネック。ドキュメント数が万単位になると顕著に効いてくる。コストと精度のトレードオフをどこに置くかは、チームの状況次第だと思う。
ハイブリッド検索を入れてから別物になった
「RAGはベクトル検索」という認識のまま運用していたが、あるときユーザーから「『障害コードE-4521』で検索しても何も出ない」というクレームをもらって気づいた。製品コードや型番、固有名詞はベクトル類似度で探すより全文検索の方が圧倒的に強い。当たり前の話なんだけど、やられるまで気づかなかった。
というわけでハイブリッド検索(Dense + Sparse)を導入した。うちはPgVector(PostgreSQL 17)を使っているので、BM25のSparse検索と組み合わせるためにRRF(Reciprocal Rank Fusion)で結果をマージしている。
from pgvector.psycopg2 import register_vector
import psycopg2
from rank_bm25 import BM25Okapi
import numpy as np
class HybridSearchEngine:
def __init__(self, conn_str: str):
self.conn = psycopg2.connect(conn_str)
register_vector(self.conn)
def dense_search(self, query_embedding: list, top_k: int = 20) -> list:
"""ベクトル類似度検索"""
cur = self.conn.cursor()
cur.execute("""
SELECT id, content, metadata,
1 - (embedding <=> %s::vector) AS score
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_embedding, query_embedding, top_k))
return cur.fetchall()
def sparse_search(self, query_tokens: list, top_k: int = 20) -> list:
"""BM25ベースの全文検索(tsvectorを使用)"""
cur = self.conn.cursor()
query_str = " | ".join(query_tokens)
cur.execute("""
SELECT id, content, metadata,
ts_rank(to_tsvector('japanese', content),
to_tsquery('japanese', %s)) AS score
FROM documents
WHERE to_tsvector('japanese', content) @@ to_tsquery('japanese', %s)
ORDER BY score DESC
LIMIT %s
""", (query_str, query_str, top_k))
return cur.fetchall()
def rrf_merge(self, dense_results: list, sparse_results: list, k: int = 60) -> list:
"""Reciprocal Rank Fusion"""
scores = {}
for rank, row in enumerate(dense_results):
doc_id = row[0]
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)
for rank, row in enumerate(sparse_results):
doc_id = row[0]
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1)
sorted_ids = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_ids[:10]
RRFは直感的にわかりやすいし、重みの調整が不要なのが地味に良い。Dense/Sparseそれぞれのスコアスケールが違っても正規化なしでマージできる。個人的にはこういう「理屈がシンプルで動く」手法が好きで、こねくり回した重み付けより結果も安定していた。
ただし日本語のtsvectorはそのままだと精度が低いので、PGroongaやJanomeとの組み合わせを検討している最中だ。英語ドキュメントなら素のPostgreSQLで十分なんだけど、うちは日本語ドキュメントがメインなのでここが課題として残っている。
リランキングを追加してから回答品質が体感で2段階上がった
検索で上位20件取れても、そのままLLMに渡すと「なんとなく関連してるけど微妙にズレた」回答が返ってくることが多かった。これをどうにかしようとリランキングを入れたんだけど、マジで効果があった。
CohereのRerank APIかCross-Encoderモデルを使う方法が主流だけど、うちはコスト面からまずローカルのCross-Encoderを試した。
from sentence_transformers import CrossEncoder
from typing import List, Tuple
class RerankerPipeline:
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
# 2026年時点では日本語対応の cl-nagoya/ruri-reranker-small が良い
self.model = CrossEncoder("cl-nagoya/ruri-reranker-small")
def rerank(
self,
query: str,
candidates: List[str],
top_n: int = 5
) -> List[Tuple[int, float, str]]:
"""
Returns: [(original_rank, score, content), ...]
"""
pairs = [(query, doc) for doc in candidates]
scores = self.model.predict(pairs)
ranked = sorted(
enumerate(zip(scores, candidates)),
key=lambda x: x[1][0],
reverse=True
)[:top_n]
return [(orig_rank, score, content)
for orig_rank, (score, content) in ranked]
# 使用例
reranker = RerankerPipeline()
query = "障害コードE-4521の対処方法"
candidates = [chunk["content"] for chunk in retrieved_chunks]
reranked = reranker.rerank(query, candidates, top_n=5)
for rank, (orig_rank, score, content) in enumerate(reranked):
print(f"Rank {rank+1} (was {orig_rank+1}): score={score:.3f}")
# Rank 1 (was 7): score=0.892
# Rank 2 (was 2): score=0.743
# Rank 3 (was 15): score=0.681
出力の Rank 1 (was 7) という部分が面白くて、ベクトル検索では7番目だったチャンクがリランク後に1位になっている。つまり「類似度は低いのに実際には一番答えに近い」という逆転が普通に起きる。ここに気づいてからリランクへの信頼度が上がった。
cl-nagoya/ruri-reranker-small はNagoya大学が公開している日本語特化のリランカーで、2025年末から使い始めたけど日本語クエリへの適合が格段に良くなった。ms-marco系は英語ドキュメントに強くて日本語は若干ズレる印象がある。日本語メインのチームなら素直にruriを使った方がいい。
RAGパイプライン全体のアーキテクチャはこんな感じになっている。
flowchart TB
subgraph Ingestion["インジェスト パイプライン"]
D1["ドキュメント投入"] --> C1["階層チャンキング"]
C1 --> C2["セマンティック細分化"]
C2 --> E1["Embedding生成\n(text-embedding-3-large)"]
E1 --> DB[("PgVector\nPostgreSQL17")]
C2 --> FT[("全文検索インデックス\n(tsvector)")]
end
subgraph Query["クエリ パイプライン"]
Q["ユーザークエリ"] --> QE["クエリ拡張\n(HyDE / Multi-query)"]
QE --> DS["Dense検索\nベクトル類似度"]
QE --> SS["Sparse検索\nBM25/全文検索"]
DB --> DS
FT --> SS
DS --> RRF["RRF Fusion\n上位20件"]
SS --> RRF
RRF --> RR["Reranking\n(ruri-reranker)"]
RR --> CTX["コンテキスト構築\n上位5件"]
CTX --> LLM["LLM\n(GPT-4o / Claude 3.7)"]
Q --> LLM
LLM --> ANS["回答生成"]
end
クエリ拡張(HyDE: Hypothetical Document Embeddings)も地味に効いている。ユーザーの短いクエリだけだと検索がうまくヒットしないことがあるので、LLMに「このクエリに答えそうな文書の断片を仮生成させ」て、それをEmbeddingして検索する。質問の言い回しが検索インデックスと乖離している場合に特に有効だった。「ユーザーが知らない用語でドキュメントが書かれている」というシチュエーションで劇的に改善した。
評価基準を持たないと永遠にフィーリングで改善することになる
「なんとなく良くなった気がする」で運用を続けていた時期があって、それが一番まずかった。チャンキングを変えた、リランクを追加した、でも本当に良くなったのかを客観的に測れていなかった。改善した気になってるだけで実は退化してたケースも、後から振り返ると何度かあったと思う。
今はRAGAS(RAG Assessment)をベースに評価パイプラインを組んでいる。
from ragas import evaluate
from ragas.metrics import (
faithfulness, # 回答がコンテキストに忠実か
answer_relevancy, # 回答がクエリに関連しているか
context_precision, # 取得したコンテキストの精度
context_recall, # 関連コンテキストの網羅率
)
from datasets import Dataset
# 評価セットの作成(最初の100件は手動でGTを作った...つらかった)
eval_data = {
"question": questions,
"answer": generated_answers,
"contexts": retrieved_contexts,
"ground_truth": ground_truths,
}
dataset = Dataset.from_dict(eval_data)
result = evaluate(
dataset=dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall,
]
)
print(result)
実際に測った改善の推移がこれ。v1からv5まで、施策を打つたびにスコアが上がっているのが可視化できるようになってから、「次は何をすべきか」の議論が格段にしやすくなった。
xychart-beta
title "RAG評価スコア推移(Context Recall)"
x-axis ["v1固定チャンク", "v2+オーバーラップ", "v3+セマンティック", "v4+ハイブリッド検索", "v5+リランク"]
y-axis "Context Recall" 0.0 --> 1.0
line [0.52, 0.61, 0.69, 0.78, 0.87]
Faithfulnessは0.91まで上がっていて、これはLLMがコンテキスト外の情報で答えることが減ったことを示している。最初は0.72くらいだったので、チャンキングとリランクの改善が効いている。
各指標の意味と現在値はこんな感じ。Answer Relevancyだけまだ目標に届いていないのが悔しい。
| 指標 | 意味 | 現在値 | 目標値 |
|---|---|---|---|
| Faithfulness | 回答がコンテキストに忠実か | 0.91 | > 0.90 |
| Answer Relevancy | 回答がクエリに答えているか | 0.84 | > 0.85 |
| Context Precision | 取得チャンクの的確さ | 0.79 | > 0.80 |
| Context Recall | 必要情報の網羅率 | 0.87 | > 0.85 |
Answer Relevancyが目標に届いていないのが現状の課題で、クエリ理解の部分(クエリ分類やインテント解析)をもっと改善する必要がある。
評価データセットの構築が一番しんどかった。最初は手動で100件のQ&Aペアを作ったけど、今はLLMを使って合成テストセットを自動生成している。ただし合成データだけで評価するのは怖いので、月1回は人手でサンプリング評価もしている。どこかで手を抜くと評価自体が信頼できなくなるので、このバランスは皆さんどうやってますか?
プロンプト設計の原則についてはプロンプト設計はセンスじゃない——チーム運用2年で見えた構造化の話でも書いているので、RAGのシステムプロンプト設計に悩んでいる人はあわせて読んでもらえると参考になるかもしれない。
また、うちのRAGシステムはLLMのAPI部分にOpenAI GPT-4oとAnthropic Claude 3.7 Sonnetを使い分けているが、APIの実装詳細はOpenAI API 2026実装ガイド|GPT-4o・o1モデル活用法にまとめてある。
Graph RAGとLong Context RAGの使い分け、まだ答えが出てない
2026年時点で「次のRAG」として盛り上がっているのがGraph RAGとLong Context RAGだ。正直まだ検証中の部分が多いけど、現時点での感触を書いておく。
Graph RAG(Microsoft ResearchのGraphRAGがOSSで使えるようになった)は、ドキュメント群からエンティティと関係性をグラフとして抽出し、グラフ走査とベクトル検索を組み合わせる手法だ。うちの社内ドキュメントで試したところ、「AシステムとBシステムの関係を説明して」みたいな横断的な質問への回答精度が大幅に上がった。
一方で構築コストが高い。グラフ抽出にLLMを使うので、インジェストコストが通常RAGの5〜10倍になる。更新頻度が高いドキュメントには向かないし、グラフが正しく構築されているかの検証も大変だ。「試してみたら良かった」という体験談はよく見るけど、実際の本番運用コストを計算すると踏み切れないチームも多いんじゃないかと思う。うちもパイロット運用の段階で一時停止している状態だ。
Long Context RAGは、最近のLLMが128K〜1Mトークンのコンテキストウィンドウを持てるようになったことで注目されている。「チャンクを頑張って選ぶより、関係しそうなドキュメントをまるっと突っ込む」という発想だ。Gemini 1.5 ProやClaude 3.7の超長コンテキストを使えば、従来のRAGパイプラインが不要になるケースもある。
ただしコストとレイテンシが跳ね上がる。1クエリで数万トークン処理すると、GPT-4oだと1クエリ数円になる。ユーザー数が増えると話にならない金額になるので、「重要な質問に限定して使う」みたいな使い分けが現実的だと思っている。
3つのアプローチを並べると、違いがわかりやすいかもしれない。
flowchart LR
subgraph Traditional["従来のベクトルRAG"]
TV["ベクトルDB"] --> TR["類似チャンク検索"]
end
subgraph GraphRAG["Graph RAG"]
GV["ベクトルDB"] --> GR["類似チャンク検索"]
GG["グラフDB"] --> GC["コミュニティ要約"]
GR --> GM["マージ&統合"]
GC --> GM
end
subgraph LongContext["Long Context RAG"]
LC["全ドキュメント"] --> LR["Rerank上位N件"]
LR --> LL["128K〜1M tokenウィンドウに直接投入"]
end
GM --> ANS1["横断的質問に強い"]
LL --> ANS2["シンプルで高精度"]
TR --> ANS3["コスト効率が良い"]
ユースケースによって選ぶ手法がかなり変わってくるので、「どれが一番良い?」という問いにはまだ答えられない。各アプローチの特性をまとめるとこんな感じ。
| アプローチ | 精度 | コスト | 構築難易度 | 更新対応 | 適したユースケース |
|---|---|---|---|---|---|
| 従来ベクトルRAG | 中 | 低 | 易 | 高 | 汎用、大量ドキュメント |
| ハイブリッド+リランク | 高 | 中 | 中 | 高 | 固有名詞混在、精度重視 |
| Graph RAG | 高 | 高 | 難 | 低 | 関係性の多い知識グラフ |
| Long Context RAG | 高 | 非常に高 | 易 | 高 | 少量・高精度要求 |
まとめ
RAGを本番で1年運用して得た知見を5点に絞るとこうなる。
-
チャンキング戦略が9割:固定長分割をやめて、ドキュメントの構造を活かした階層チャンキング+セマンティック分割にするだけで、Recall@5が52%から83%まで改善した。ここへの投資は絶対に惜しまないほうがいい。
-
ハイブリッド検索は最初から入れるべき:ベクトル検索だけでは固有名詞や製品コードの検索が弱い。BM25ベースのSparse検索とRRFで組み合わせることでカバレッジが大幅に上がる。後から追加するより最初から設計に組み込む方が楽。
-
リランキングは費用対効果が高い:特に日本語の場合、
cl-nagoya/ruri-reranker-smallのようなローカルモデルでも体感で2段階くらい回答品質が上がる。レイテンシが100〜200ms増えるが十分許容範囲内。 -
評価基準なしの改善はフィーリング改善:RAGASで定量評価パイプラインを構築してから、改善のサイクルが明確になった。最初の評価データセット作りはしんどいが、絶対にやるべき。
-
Graph RAGとLong Context RAGは用途を絞って使う:汎用のRAGパイプラインをこれらで置き換えるのは時期尚早。横断的な関係性検索や高精度が求められる特定ユースケースに限定するのが現実的。
次に試すなら、この順番がおすすめ:
- 既存の固定長チャンキングをセマンティックチャンキングに切り替えてみる(まずオフラインで比較検証から)
- RRFによるハイブリッド検索をPgVectorとtsvectorで実装して検索カバレッジを測る
- RAGASで最低50件の評価セットを作り、現状のスコアを把握する
RAGは「入れれば動く」技術になってきたけど、「ちゃんと動かす」ためのチューニングポイントはまだまだ多い。皆さんのチームではどんな工夫をしていますか?特にチャンキングのベストプラクティスについて、もっと議論したいなと思っている。