RAG完成編2 ~ RAG最小実装 | SkillhubAI(スキルハブエーアイ)

RAG完成編2 ~ RAG最小実装

レッスン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. 「検索 → 文脈整形 → 回答生成」の本体関数

なにをしている?

  1. 検索で上位チャンクを集める
  2. 入れすぎないように上限カット
  3. プロンプトを組み立てる
  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_tokens2000 前後で様子見。長文PDFで不足すれば少し上げる。
    • temperature0.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. 動作確認の手順

  1. PDF Upload」でアップロード → Embedding作成 → Qdrant保存
  2. Ask My PDF(s)」へ移動
  3. 質問を入力 → 回答を生成
  4. 回答の下の「参照した文脈」を開き、根拠と矛盾がないか を確認

うまくいかないとき

  • 問いが曖昧 → 固有名詞章タイトル を足す
  • ヒットが少ない → 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を整えて、回答の読みやすさ・使いやすさを上げます。

  • 見出しや区切りの最適化
  • 参照チャンクの冒頭プレビューやハイライト
  • 「よくある質問」ボタン など

必要でしたら、このレッスンの変更点を パッチ形式(差分) でもまとめます。