RAGのデモの多くは本番環境で失敗します。10問のデモ資料では動作しても、ユーザーが未知の質問をした瞬間に壊れます。このガイドでは、意味的境界を尊重するチャンク分割、あいまいなクエリと文字どおりのクエリの両方を捉えるハイブリッド検索、ハルシネーションを防ぐプロンプト構成、規模拡大後も請求額を一定に保つコスト管理という、実際に耐えられるアーキテクチャとパターンを説明します。
重要な5つの決定
- チャンク分割戦略 — モデルよりも検索品質に大きく影響する
- 埋め込みモデル — 再現率の上限を決める
- 検索戦略 — ベクトルだけでは不十分
- プロンプト構成 — モデルがハルシネーションを起こすかを決める
- 生成モデル + キャッシュ — 単位経済性を決める
1. チャンク分割 — 凝った方法より退屈な方法
考えすぎないでください。チャンクあたり1000-1500文字、オーバーラップ100-200文字の再帰的文字分割器を使います。まず段落境界、次に文、最後に単語で分割しようとします。ドメインを問わず安定しています。
# Recursive character splitter with overlap — the boring choice that wins
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1200,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(document)
# 1200 chars ≈ 300-400 tokens — fits 5+ chunks in a Sonnet context window
# with room for system prompt + question + answerより賢そうに見えても、ほとんど役立たない方法があります。文認識モデル、Markdown対応分割器、埋め込み付きスライディングウィンドウです。悪いわけではありませんが、大きな労力に対して改善はわずかです。その労力は検索に使ってください。
2. 埋め込みモデル — text-embedding-3-largeがデフォルト
埋め込みはKunavoでは提供していません。この処理では埋め込みプロバイダーを直接呼び出してください。 /v1/embeddingsは実装済みのワイヤ形式ですが、その背後で有効なモデルがないため、これへのリクエストは失敗します。GET /v1/modelsが、呼び出し可能な対象について常に正しい情報源です。実際には、RAGパイプラインに追加で必要なのは2つ目のキーだけです。埋め込み呼び出しと生成呼び出しはもともと別々のリクエストなので、OpenAI、Voyage、またはCohereで埋め込みを作成し、Kunavoで生成してください。
text-embedding-3-largeは、OpenAIが公表している料金で、多くの本番用途における多言語対応と精度のバランスに優れています。ここでは再販しておらず、料金を掲載すべきではないため、当社ではなくOpenAIの料金ページで確認してください。チャンク分割戦略またはモデル自体を変更した場合は再埋め込みします。コンテンツ更新時は再埋め込みせず、新しいチャンクを追加するだけです。
小さい埋め込みが適している例は、英語のみの短文検索(FAQ、製品カード)です。その場合、text-embedding-3-smallは4倍安く、品質低下は無視できる程度です。多言語または技術コンテンツではlargeを使い続けてください。
3. 検索 — 純粋なベクトル検索はハイブリッドに負ける
ベクトル類似度は、あいまいな意味検索(「解約方法」→「サブスクリプション解約ポリシー」)には優れています。一方、文字どおりの語句(「SKU-A92837」や「注文番号4729」)には非常に弱いです。解決策はハイブリッド検索です。BM25キーワード検索を並行実行し、その後に逆順位融合を行います。
# Hybrid retrieval: vector similarity + BM25 keyword scoring
# Pure vector misses literal keywords (product SKUs, IDs, dates)
def retrieve(question: str, k: int = 5) -> list[dict]:
q_embed = embed([question])[0]
vector_hits = vector_db.similarity_search(q_embed, k=k * 2)
keyword_hits = bm25_search(question, k=k * 2)
# Reciprocal rank fusion — simple, robust
scores: dict[str, float] = {}
for rank, hit in enumerate(vector_hits):
scores[hit["id"]] = scores.get(hit["id"], 0) + 1 / (rank + 60)
for rank, hit in enumerate(keyword_hits):
scores[hit["id"]] = scores.get(hit["id"], 0) + 1 / (rank + 60)
top_ids = sorted(scores, key=scores.get, reverse=True)[:k]
return [chunk_by_id[i] for i in top_ids]適切な埋め込みモデルを選んだ後では、最大の品質向上です。保留した100問の評価でrecall@5を追跡してください。BM25追加後に70%から90%へ上がったなら、「わかりません」という失敗の3分の1を取り除いたことになります。
4. プロンプト構成 — ハルシネーションを防ぐ3つのパターン
# Final RAG prompt structure — three sections, citations enforced
SYSTEM_PROMPT = """You answer based exclusively on the supplied Context.
- Cite the [doc_id] for each factual claim.
- If the context doesn't answer the question, say "I don't have that
information" — do not extrapolate or use general knowledge.
- Be concise. No throat-clearing."""
def answer(question: str) -> dict:
chunks = retrieve(question, k=5)
context = "\n\n---\n\n".join(
f"[doc:{c['id']}] {c['text']}" for c in chunks
)
resp = client.chat.completions.create(
model="claude-sonnet-4-6",
messages=[
{"role": "system", "content": [{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}]},
{"role": "user", "content": f"# Context\n{context}\n\n# Question\n{question}"},
],
max_tokens=600,
)
return {
"text": resp.choices[0].message.content,
"sources": [c["id"] for c in chunks],
}システムプロンプトに必須の3項目:
- 出典を引用する — モデルに各主張へ
[doc:42]を付けさせます。引用IDが実在しなければ、ハルシネーションを検出できています。 - 明示的に拒否する — 「コンテキストに答えがなければ、その情報を持っていないと言う」と指示します。これがないと、モデルは世界知識から補完します。
- 簡潔な出力 — 短い回答は正確な回答と相関します。最大600トークンが本番環境に適したデフォルトです。
5. 生成モデル + キャッシュ — コスト層
デフォルトはClaude Sonnet 4.6です。コストを抑える必要がある場合はClaude Haiku 4.5が安価なフォールバックです。システムプロンプトは常にcache_controlで囲んでください。システムプロンプトは毎回同じなので、キャッシュによりその部分を入力料金の10%に削減できます。
本番規模での現実的なクエリ単価(コンテキスト5K、出力500、キャッシュ有効):
| モデル | クエリあたりのコスト | 1日10,000クエリの場合 | Sonnetとの品質比較 |
|---|---|---|---|
claude-sonnet-4-6 | ~$0.007 | 約$70/日 | 基準値 |
claude-haiku-4-5 | ~$0.001 | 約$10/日 | 約85%、4倍安い |
1日10,000クエリの場合、Sonnetは$70/日、Haikuは$10/日です。顧客向け、法務、医療など重要度の高い用途にはSonnet、社内処理や大量処理にはHaikuを選んでください。
本番環境で壊れるもの(およびその検出方法)
- 分布ドリフト:学習コーパスが実際のユーザー質問と一致しなくなる。毎週100件のクエリを抽出し、recall@5を手動で確認する。
- 古い埋め込み:元文書は更新されたが、埋め込みが更新されていない。毎週、インデックスサイズとソースサイズを追跡する。
- 偽の引用:モデルが実在するように見える文書IDを作る。表示前に、すべての
[doc:N]を実際に取得したID一覧と照合する。 - レイテンシーの急増:ベクトルDBが100万ベクトルを超えると急激に悪化する。HNSWインデックスを使い、マルチテナントの場合はテナント単位で分割する。
本番導入の4週間ロードマップ
- 1週目:100文書、10問のテスト、手動評価でプロトタイプを作る
- 2週目:全コーパスに拡張し、ハイブリッド検索を構築し、100問の評価セットを作成する
- 3週目:評価を反復しながらチャンク分割とシステムプロンプトを調整し、社内ユーザーに公開する
- 4週目:監視、コスト予算、出典表示UI付きで一般公開する
よくある質問
本番RAGシステムでは、どのチャンクサイズを使うべきですか?
チャンクあたり1,000–1,500文字、オーバーラップ100–200文字を目安にし、段落境界、次に文、最後に単語の順で分割する再帰的文字分割器を使います。文認識モデル、Markdown対応分割器、スライディングウィンドウは、はるかに大きな労力でわずかに改善します。その労力は検索に使ったほうが高い効果を得られます。
RAGパイプラインでは、どの埋め込みモデルを使うべきですか?
多言語および技術コンテンツにはtext-embedding-3-largeがデフォルトです。FAQや製品カードのような英語のみの短文検索では、text-embedding-3-smallは約4倍安く、品質低下は無視できる程度です。なお、埋め込みはKunavoでは提供していません。/v1/embeddingsは実装済みのワイヤ形式ですが、その背後で有効なモデルはないため、埋め込み処理はOpenAI、Voyage、またはCohereを直接呼び出し、生成処理はKunavoを呼び出します。いずれにせよ別々のリクエストなので、必要なのは2つ目のキーだけで、パイプラインの費用は増えません。
RAGの検索にベクトル検索だけで十分ですか?
いいえ。ベクトル類似度はあいまいな意味検索には適していますが、SKUや注文番号などの文字どおりの語句には弱いです。BM25キーワード検索を並行実行し、逆順位融合で統合すると、通常recall@5は約70%から約90%に向上します。適切な埋め込みモデルを選んだ後では、最大の品質向上です。
RAGシステムのハルシネーションを防ぐにはどうすればよいですか?
システムプロンプトに3つのパターンを入れます。すべての事実主張に[doc:N]引用を要求すること、コンテキストに答えがない場合は情報を持っていないと明言するよう指示すること、回答を短く保つことです。本番環境では出力最大600トークンが適切なデフォルトです。そのうえで、表示前に引用されたすべてのIDを取得済みセットと照合します。実在しないIDがあれば、ハルシネーションを検出したことになります。
本番RAGのクエリにはどの程度の費用がかかりますか?
コンテキスト5K、出力500トークン、プロンプトキャッシュ有効の場合、Claude Sonnet 4.6ではクエリあたり約$0.007、Claude Haiku 4.5では約$0.001です(4倍安く、品質は約85%)。1日10,000クエリでは、Sonnetで約$70/日、Haikuで約$10/日です。