では、チュートリアルに従って、ベクターデータベースを立ち上げて、いくつかのドキュメントを埋め込み(embed)て、新しくピカピカのRAGシステムに質問を投げたわけです。答え?完全に間違い。あるいは、もっと悪い——裏付けのない根拠(引用)を添えて、自信満々に間違っている。
その状況、私は経験しました。製品環境で2回です。RAGシステムを作るときに本当に牙をむく問題と、検索品質を「恥ずかしいデモ」から「本当に役に立つ」へ引き上げてくれた改善策を、あなたにも順を追って説明します。
根本的な問題:検索(retrieval)は見た目より難しい
多くのRAGチュートリアルでは、簡単そうに見せます。ドキュメントをチャンク化して、埋め込みを作って、類似検索を行い、結果をプロンプトに詰めるだけです。——このパイプラインは、同じトピックのドキュメントが50個あるような“おもちゃ”のデモでは素晴らしく機能します。
しかし、実データを扱い始めた瞬間に崩れます。ドキュメントの形式はバラバラ、詳細度はまちまち、クエリは曖昧で、分割したせいで文脈が失われたチャンクが生まれる——これらが、実際にあなたのシステムを殺してしまう要因です。
原因の根はほぼいつも同じです:検索ステップが無関係なチャンクを返してしまうので、プロンプトエンジニアリングをどれだけ頑張っても、悪い文脈を修正することはできません。
失敗#1:素朴なチャンク分割が意味を壊す
これは私が最初に学んだ、痛い教訓でした。私は、すべてのチュートリアルが推奨しているように、トークン数で分割しつつ一部重なり(overlap)を持たせていました:
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 「デフォルト」のやり方。みんな最初はこれ
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(docs)
問題は?技術ドキュメントの“途中”から切り出した500トークンのチャンクは、何を説明しているのかについて文脈がほぼないことがよくあります。「タイムアウトパラメータの設定方法」についての段落はあっても、それがどのサービスやコンポーネントに属しているのかが分からないのです。
改善策:セマンティックなチャンク分割+メタデータの強化
2つの変更が、決定的な違いを生みました。まず、ドキュメント構造を尊重するチャンク化戦略に切り替えます:
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
# 文の間の意味的な類似性に基づいて分割する
# 任意のトークン数ではなく
semantic_splitter = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_percentile_threshold=85
)
chunks = semantic_splitter.split_documents(docs)
次に、そしてこれが多くの人がスキップするポイントですが、各チャンクに文脈を前置します:
def enrich_chunk(chunk, parent_doc):
"""チャンクがそれ単体で理解できるように、ドキュメント全体の文脈を追加する。"""
prefix = f"Document: {parent_doc.title}
"
prefix += f"Section: {chunk.metadata.get('section', 'Unknown')}
"
prefix += f"Topic: {parent_doc.category}
"
chunk.page_content = prefix + chunk.page_content
return chunk
これは、ときに「contextual retrieval(文脈付き検索)」と呼ばれます。考え方はシンプルで、すべてのチャンクが単体でも理解できるべきだということです。これを実装した後、私の検索の精度ははっきりと改善しました——「タイムアウト設定」に関するチャンクには、どのサービスに属するのかを示すメタデータが付くようになったのです。
失敗#2:埋め込みの類似度は意味的類似度ではない
これは微妙です。ユーザーが「支払いフローのエラーをどう扱えばいい?」と聞くと、上位結果が、まったく関係ない別セクションにある「エラーハンドリングのベストプラクティス」についてのチャンクになります。単語は一致しています。意味は一致していません。
埋め込みに対するコサイン類似度は、語彙的な関係をかなりうまく捉えられますが、ドメイン固有の意図には苦手です。
改善策:ハイブリッド検索
純粋なベクター検索には盲点があります。純粋なキーワード検索(BM25)にも別の盲点があります。両方を組み合わせましょう:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
返却形式: {"translated": "翻訳されたHTML"}# ベクター検索はセマンティックな一致を捉える
vector_retriever = Chroma.from_documents(
chunks, embedding_function
).as_retriever(search_kwargs={"k": 10})
# BM25は、埋め込みが取りこぼす厳密なキーワード一致を捉える
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10
# 相互順位融合(Reciprocal Rank Fusion)は両方の結果セットを統合する
hybrid_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.6, 0.4] # ドメインに合わせて調整する
)
重みは重要です。技術ドキュメントの場合、BM25の重みを0.4〜0.5に上げると助けになることがあると分かりました。ユーザーはしばしば、正確な関数名やエラーコードを検索するからです。より会話的なナレッジベースでは、ベクター検索をより重く見てください。
失敗 #3: たくさんのチャンクをプロンプトに詰め込みすぎる
文脈は多いほど良いですよね?違います。kを4から15に増やしたところ、回答の質が低下するのを見て、痛い目を見ました。
LLMは無関係なチャンクに圧倒され、互いに関係のない文脈同士を結びつける「幻覚」を始めます。これは「真ん中で迷子になる(lost in the middle)」問題です。モデルはコンテキストウィンドウの先頭と末尾のほうにより注意を払うからです。
解決策: 生成の前に再ランキングする
広く取得してから、強く再ランキングする:
from sentence_transformers import CrossEncoder
# クロスエンコーダは遅いですが、再ランキングでははるかに正確です
# バイエンコーダの類似度よりも、再ランキングに向いています
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank_chunks(query, chunks, top_k=4):
pairs = [(query, chunk.page_content) for chunk in chunks]
scores = reranker.predict(pairs)
# 再ランキングのスコアで並べ替え、上位だけを残す
ranked = sorted(zip(scores, chunks), reverse=True)
return [chunk for _, chunk in ranked[:top_k]]
# 20件の候補を取得し、上位4件に再ランキングする
candidates = hybrid_retriever.get_relevant_documents(query)
final_chunks = rerank_chunks(query, candidates, top_k=4)
このパターン——過剰に取得してから再ランキングする——は一貫して、少ないチャンクを取得するだけの場合よりも常に優れていました。クロスエンコーダはクエリとチャンクを一緒に見ます。そのため、埋め込みの類似度では見逃す関連性のシグナルも捉えられます。
失敗 #4: 評価をしないと盲目になる
おそらく最大のミスは、取得の品質を測るための体系的な方法なしに、何週間も放置してしまったことです。ランダムなクエリを投げて結果を「雰囲気」で確認していました。やめましょう。
解決策: 早い段階で評価セットを作る
凝ったものは不要です。スプレッドシートで30〜50件の「クエリ/期待される回答」ペアがあれば、驚くほど先に進めます:
import json
def evaluate_retrieval(retriever, eval_set, k=4):
"""基本的な取得(リトリーバル)の評価: 正しいチャンクが表示されるか?"""
hits = 0
for item in eval_set:
results = retriever.get_relevant_documents(item["query"])
retrieved_texts = [r.page_content for r in results[:k]]
返却形式: {"translated": "翻訳されたHTML"}# 期待されるソースドキュメントが結果に含まれているか確認します
if any(item["expected_source"] in text for text in retrieved_texts):
hits += 1
recall = hits / len(eval_set)
print(f"Recall@{k}: {recall:.2%}")
return recall
チャンク分割、埋め込み、または検索(リトリーバル)の戦略を変更するたびに、この評価(eval)を実行しました。手作業のテストでは見逃してしまうような退行(レグレッション)を検出でき、変更を本番に反映するときの自信にもつながりました。
予防策: 私が最初から持っていたかったチェックリスト
こうしたシステムをいくつか作った後に、最初の一日目から違うことをするならこうします:
- 一番難しいクエリから始める。 コードを書く前に、ユーザーや関係者から実際の質問を10個つかみ取ります。それらの検索が対応できないなら、プロンプトのチューニングをどれだけしても救えません。
- すべてログに残す。 クエリ、取得したチャンク、リランカー(再ランキング)のスコア、最終的な回答を保存します。本番で何かがうまくいかないとき(必ず起きます)、どの段階で失敗したのかを把握する必要があります。
- チャンクサイズは「一つで全て」ではない。 コードブロック付きの技術ドキュメントには、より大きなチャンクが必要です。FAQ形式のコンテンツは、小さく自己完結したチャンクのほうがうまくいきます。ドキュメントの種類ごとに複数の戦略をテストしてください。
- メタデータのフィルタリングを省略しない。 ドキュメントに自然なカテゴリ(製品分野、ドキュメント種別、日付など)があるなら、ベクトル類似度が働く前にメタデータフィルタで検索空間を絞り込みます。これはコストが安く、驚くほど効果的です。
- 埋め込みモデルは、思っているより重要ではない。 埋め込みモデルをベンチマークするのに何日も費やしましたが、せいぜい2〜3%の改善しか得られませんでした。ハイブリッド検索+リランキングを実装するのに半日しかかけませんでしたが、15%以上の改善が得られました。まずは検索(リトリーバル)のアーキテクチャに集中してください。
居心地の悪い真実
デモで動くRAGシステムを作るのは週末のプロジェクトです。確実に本番で動くようにするのは、継続的なエンジニアリング作業です。検索パイプラインには、他の重要なシステムと同じだけの注意が必要です――監視、評価、反復(イテレーション)です。
良いニュースは、上記の修正は複雑ではないということです。セマンティックなチャンク分割、ハイブリッド検索、リランキング、そして基本的な評価ハーネス(eval harness)があれば、ほとんどのつらい失敗パターンを乗り越えられます。まずそこから始め、すべてを測定し、実際のユーザーの実際のクエリに基づいて改善を回してください。
LLMがボトルネックになることは、ほぼありません。ボトルネックは検索(リトリーバル)です。
返却形式: {"translated": "翻訳されたHTML"}![[Boost]](/_next/image?url=https%3A%2F%2Fmedia2.dev.to%2Fdynamic%2Fimage%2Fwidth%3D800%252Cheight%3D%252Cfit%3Dscale-down%252Cgravity%3Dauto%252Cformat%3Dauto%2Fhttps%253A%252F%252Fdev-to-uploads.s3.amazonaws.com%252Fuploads%252Fuser%252Fprofile_image%252F3618325%252F470cf6d0-e54c-4ddf-8d83-e3db9f829f2b.jpg&w=3840&q=75)



