レッスン2:検索結果を“答え”に変える(RAG最小実装・ていねい解説)
このレッスンでは、前編で作った 「検索(R)」 に、「文脈の埋め込み(A)」 と 「回答生成(G)」 を加えて、 PDFに質問 → 根拠つきで答えが返ってくる ところまで仕上げます。 LangChainは使わず、既存コードに 少しずつ機能を足す 方針です。
0. 到達イメージ(なにが増える?)
あなたの質問
↓(Embedding化)
Qdrantで類似チャンク検索(R)
↓(上位チャンクを選ぶ)
チャンクをプロンプトに埋め込む(A)
↓
ChatGPTが回答生成(G)
↓
回答 と 参照(根拠)をUIに表示
ポイント:
- 検索結果(チャンク)を そのまま表示 ではなく、プロンプトに“足して” 回答を作ってもらいます。
- 「分からなければ分からない」 を明示し、無理な推測を減らします。
- 回答といっしょに 参照チャンク(根拠) を見せ、納得感を大切にします。
1. まずは小さな定数を追加(使うモデルを決める)
なぜ必要? どのモデルに答えを作ってもらうかを一か所で管理すると、後から差し替えやすくなります。
既存ファイルの定数群の近くに追記してください。
# 回答に使うチャットモデル(軽さと品質のバランス)
CHAT_MODEL = "gpt-4o-mini" # 必要に応じて "gpt-4o" などへ
temperature
(創造性)は次の関数で指定します。はじめは 0.2 程度で大丈夫です(事実寄り)。
2. 文脈を入れすぎないための“安全弁”
なぜ必要? 検索で複数のチャンクを集めますが、詰め込みすぎると長くなりすぎ ます。 この関数は、「最大トークン数までで切る」だけの 素直で安全な制限 です。
def clip_contexts_by_tokens(contexts, max_tokens=2000):
"""複数テキストをつなぐ際に、トークン数の上限で安全にカット"""
if not contexts:
return []
enc = tiktoken.encoding_for_model("gpt-4") # 互換のあるトークナイザーでOK
out, used = [], 0
for c in contexts:
t = enc.encode(c)
if used + len(t) > max_tokens:
remain = max_tokens - used
if remain > 0:
out.append(enc.decode(t[:remain]))
break
out.append(c)
used += len(t)
return out
- 目安:
max_tokens=2000
なら、プロンプト(説明文)と質問分を足しても多くのモデルで安全圏です。 長いPDFで文脈が足りなければ、後で top_k を増やす/チャンクサイズを見直す でもOK。
3. プロンプト設計(どう頼むかを決める)
なぜ必要? RAGでは「検索で集めた文脈を どう提示するか」がカギです。 ここでは「根拠にないことは言わない」「日本語で丁寧に」など、方針を明示します。
def build_messages(query, contexts):
"""Chat Completions用メッセージを組み立てる(方針を明示)"""
context_text = "\n\n---\n\n".join(contexts)
system_text = (
"あなたはPDFの内容を根拠として、日本語で丁寧に回答するアシスタントです。"
"分からない場合は無理に推測せず「分かりません」と答えてください。"
"与えられた文脈にない情報は出さないでください。"
)
user_text = (
f"次の文脈に基づいて質問に答えてください。\n\n"
f"[文脈]\n{context_text}\n\n"
f"[質問]\n{query}\n\n"
f"[回答](日本語で簡潔に)"
)
return [
{"role": "system", "content": system_text},
{"role": "user", "content": user_text},
]
コツ:
- 制約(出さない・推測しない) を最初に伝えると、ブレが減ります。
- 区切り線
---
は文脈の見通しをよくします。
4. 「検索 → 文脈整形 → 回答生成」の本体関数
なにをしている?
- 検索で上位チャンクを集める
- 入れすぎないように上限カット
- プロンプトを組み立てる
- モデルに投げて回答を受け取る
def answer_query(query, api_key, top_k=5, max_context_tokens=2000, temperature=0.2):
"""PDFに基づいた質問応答(RAGの最小実装)"""
# 1) 類似チャンクを検索(R)
results = search_similar_chunks(query, api_key, top_k=top_k)
if not results:
return "関連する情報が見つかりませんでした。別の聞き方もお試しください。", []
# 2) 文脈をまとめて上限カット(A)
raw_contexts = [r["text"] for r in results]
contexts = clip_contexts_by_tokens(raw_contexts, max_tokens=max_context_tokens)
# 3) メッセージを組み立て(A)
messages = build_messages(query, contexts)
# 4) 回答を生成(G)
client = OpenAI(api_key=api_key)
resp = client.chat.completions.create(
model=CHAT_MODEL,
messages=messages,
temperature=temperature,
)
answer = resp.choices[0].message.content.strip()
return answer, results
パラメータの考え方
top_k
:まずは 3〜5 が無難。少なすぎると根拠不足、多すぎるとノイズ増。max_context_tokens
:2000 前後で様子見。長文PDFで不足すれば少し上げる。temperature
:0.2 は安定寄り。要約っぽくしたい時は 0.3〜0.5 も試せます。
5. Streamlitの質問ページを「QA表示」に
なにが増える?
- 質問欄に加え、検索数(top_k)・文脈上限・温度 を操作できるUI
- 回答 と 参照チャンク(スコアつき) の表示
def page_ask_my_pdf():
"""質問ページ(RAG最小実装)"""
st.title("🤔 Ask My PDF(s)")
st.markdown("アップロードしたPDFの内容に基づいて質問できます。")
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
st.warning("⚠️ まずPDF UploadページでAPIキーを設定してください")
return
st.markdown("---")
# 入力UI
query = st.text_input("質問を入力してください", placeholder="例:この資料の売上の要点は?")
col1, col2, col3 = st.columns([1,1,1])
with col1:
top_k = st.slider("検索チャンク数(top_k)", 1, 10, 5)
with col2:
max_ctx = st.slider("文脈の最大トークン", 500, 4000, 2000, step=100)
with col3:
temp = st.slider("創造性(temperature)", 0.0, 1.0, 0.2, step=0.1)
if query and st.button("💬 回答を生成", type="primary"):
with st.spinner("AIが考えています..."):
answer, refs = answer_query(
query=query,
api_key=api_key,
top_k=top_k,
max_context_tokens=max_ctx,
temperature=temp,
)
# 回答表示
st.subheader("🧠 回答")
st.write(answer)
# 参照表示(根拠)
st.subheader("🔎 参照した文脈(根拠)")
if refs:
for i, r in enumerate(refs, 1):
with st.expander(f"チャンク {i}(類似度: {r['score']:.3f})"):
st.write(r["text"])
else:
st.caption("参照できる文脈が見つかりませんでした。")
# (任意)確認用:検索テストを残したい場合
st.markdown("---")
with st.expander("🔍 類似検索テスト(確認用)"):
demo_q = st.text_input("検索だけ試す(例:結論を教えて)", key="demo_q")
if demo_q and st.button("🔎 検索", key="demo_btn"):
with st.spinner("検索中..."):
demo_results = search_similar_chunks(demo_q, api_key, top_k=3)
if demo_results:
for i, r in enumerate(demo_results, 1):
st.write(f"**結果 {i}** 類似度: {r['score']:.3f}")
st.write(r["text"])
else:
st.info("関連するチャンクが見つかりませんでした。")
UIの意図
- まず 最小構成で動く体験 を作り、
- 後のレッスンで「見やすさ・使いやすさ(表示の整え、プレビュー、ハイライト)」を足します。
6. 動作確認の手順
- 「PDF Upload」でアップロード → Embedding作成 → Qdrant保存
- 「Ask My PDF(s)」へ移動
- 質問を入力 → 回答を生成
- 回答の下の「参照した文脈」を開き、根拠と矛盾がないか を確認
うまくいかないとき
- 問いが曖昧 → 固有名詞 や 章タイトル を足す
- ヒットが少ない →
top_k
を増やす(3→5→8) - 答えが薄い →
max_context_tokens
を増やす、PDF自体の該当部分が短い可能性を疑う - PDFが画像ベース → テキスト抽出が空 になっていないか確認(必要ならOCR)
7. 小さな知識(初心者向けの補足)
- スコア(類似度):Qdrantの検索結果
result.score
は「近さの指標」です。数字が大きいほど“似ている”と考えてOK。 - チャンクの重複:似た内容が並ぶことがあります。精度を上げる時は 重複抑制(MMR) も検討します(今回は最小実装なので未使用)。
- プロンプトの言語:文脈も質問も日本語なら、多くの場合そのまま日本語で返ります。確実にしたい時は 「日本語で」 を明記。
8. このレッスンのまとめ
- 検索結果(R)を プロンプトで補強(A) し、モデルに 回答生成(G) させる流れが完成。
- 無理な推測はしない/根拠も見せる という設計で、安心して試せる形になりました。
- まずは 最小構成で体験 を作ることが大切です。必要に応じて後から調整できます。
完成コードのdiff
以下に書かれている部分が今回追加した部分になります。
--- before/app.py
+++ after/app.py
@@
EMBEDDING_MODEL = "text-embedding-3-small"
CHUNK_SIZE = 500 # トークン数
CHUNK_OVERLAP = 50 # オーバーラップ
+CHAT_MODEL = "gpt-4o-mini" # ★追加:回答用チャットモデル(差し替えやすく定数化)
@@
def search_similar_chunks(query, api_key, top_k=3):
"""類似チャンクを検索(次章で使用)"""
@@
except Exception as e:
st.error(f"検索エラー: {e}")
return []
+# ======== ★ ここからレッスン2で追加したRAG用の関数(A+G) ========
+def clip_contexts_by_tokens(contexts, max_tokens=2000):
+ """
+ 複数チャンクを連結する際に、トークン数の上限で安全にカット。
+ 入れすぎ防止のシンプルな“安全弁”。
+ """
+ if not contexts:
+ return []
+ enc = tiktoken.encoding_for_model("gpt-4")
+ out, used = [], 0
+ for c in contexts:
+ t = enc.encode(c)
+ if used + len(t) > max_tokens:
+ remain = max_tokens - used
+ if remain > 0:
+ out.append(enc.decode(t[:remain]))
+ break
+ out.append(c)
+ used += len(t)
+ return out
+
+def build_messages(query, contexts):
+ """
+ モデルに渡すメッセージ(プロンプト)を組み立て。
+ - 文脈にないことは言わない
+ - 分からなければ「分かりません」
+ - 日本語で丁寧に
+ """
+ context_text = "\n\n---\n\n".join(contexts)
+ system_text = (
+ "あなたはPDFの内容を根拠として、日本語で丁寧に回答するアシスタントです。"
+ "分からない場合は無理に推測せず「分かりません」と答えてください。"
+ "与えられた文脈にない情報は出さないでください。"
+ )
+ user_text = (
+ f"次の文脈に基づいて質問に答えてください。\n\n"
+ f"[文脈]\n{context_text}\n\n"
+ f"[質問]\n{query}\n\n"
+ f"[回答](日本語で簡潔に)"
+ )
+ return [
+ {"role": "system", "content": system_text},
+ {"role": "user", "content": user_text},
+ ]
+
+def answer_query(query, api_key, top_k=5, max_context_tokens=2000, temperature=0.2):
+ """
+ PDFに基づいた質問応答(RAG最小実装)
+ 1) 類似チャンク検索(R)
+ 2) 文脈整形・上限カット(A)
+ 3) メッセージ組み立て(A)
+ 4) 回答生成(G)
+ """
+ # 1) 類似チャンク(R)
+ results = search_similar_chunks(query, api_key, top_k=top_k)
+ if not results:
+ return "関連する情報が見つかりませんでした。別の聞き方もお試しください。", []
+
+ # 2) 文脈をまとめて上限カット(A)
+ raw_contexts = [r["text"] for r in results]
+ contexts = clip_contexts_by_tokens(raw_contexts, max_tokens=max_context_tokens)
+
+ # 3) メッセージ組み立て(A)
+ messages = build_messages(query, contexts)
+
+ # 4) 回答生成(G)
+ client = OpenAI(api_key=api_key)
+ resp = client.chat.completions.create(
+ model=CHAT_MODEL,
+ messages=messages,
+ temperature=temperature,
+ )
+ answer = resp.choices[0].message.content.strip()
+ return answer, results
+# ======== ★ ここまで追加 ========
@@
-def page_ask_my_pdf():
- """質問ページ(簡易版)"""
- st.title("🤔 Ask My PDF(s)")
- st.markdown("""
- ### 🎯 このページでできること
- アップロードしたPDFの内容について質問できます!
- (完全版は次の章で実装します)
- """)
-
- # APIキーの確認
- api_key = os.getenv("OPENAI_API_KEY")
- if not api_key:
- st.warning("⚠️ まずPDF Uploadページでセットアップを完了してください")
- return
-
- st.markdown("---")
-
- # 検索テスト(デモ)
- st.subheader("🔍 類似検索テスト")
- st.write("PDFの内容から関連する部分を検索します")
-
- query = st.text_input(
- "検索したい内容を入力してください",
- placeholder="例:売上について教えて"
- )
-
- if query:
- if st.button("🔍 検索", type="primary"):
- with st.spinner("検索中..."):
- results = search_similar_chunks(query, api_key, top_k=3)
-
- if results:
- st.success(f"✅ {len(results)}件の関連チャンクが見つかりました")
-
- for i, result in enumerate(results, 1):
- with st.expander(f"📄 結果 {i} (類似度: {result['score']:.3f})"):
- st.write(result['text'])
- else:
- st.warning("関連するチャンクが見つかりませんでした")
- st.info("PDFをアップロードしてから検索してください")
+def page_ask_my_pdf():
+ """質問ページ(RAG最小実装)"""
+ st.title("🤔 Ask My PDF(s)")
+ st.markdown("アップロードしたPDFの内容に基づいて質問できます。")
+
+ # APIキーの確認
+ api_key = os.getenv("OPENAI_API_KEY")
+ if not api_key:
+ st.warning("⚠️ まずPDF UploadページでAPIキーを設定してください")
+ return
+
+ st.markdown("---")
+
+ # 入力UI
+ query = st.text_input("質問を入力してください", placeholder="例:この資料の売上の要点は?")
+ col1, col2, col3 = st.columns([1,1,1])
+ with col1:
+ top_k = st.slider("検索チャンク数(top_k)", 1, 10, 5)
+ with col2:
+ max_ctx = st.slider("文脈の最大トークン", 500, 4000, 2000, step=100)
+ with col3:
+ temp = st.slider("創造性(temperature)", 0.0, 1.0, 0.2, step=0.1)
+
+ if query and st.button("💬 回答を生成", type="primary"):
+ with st.spinner("AIが考えています..."):
+ answer, refs = answer_query(
+ query=query,
+ api_key=api_key,
+ top_k=top_k,
+ max_context_tokens=max_ctx,
+ temperature=temp,
+ )
+
+ st.subheader("🧠 回答")
+ st.write(answer)
+
+ st.subheader("🔎 参照した文脈(根拠)")
+ if refs:
+ for i, r in enumerate(refs, 1):
+ with st.expander(f"チャンク {i}(類似度: {r['score']:.3f})"):
+ st.write(r["text"])
+ else:
+ st.caption("参照できる文脈が見つかりませんでした。")
次回(レッスン3)
UIを整えて、回答の読みやすさ・使いやすさを上げます。
- 見出しや区切りの最適化
- 参照チャンクの冒頭プレビューやハイライト
- 「よくある質問」ボタン など
必要でしたら、このレッスンの変更点を パッチ形式(差分) でもまとめます。