llama3.1 と gpt-oss で役割分離したRAG実装 ─ function calling と生成を分ける

AIテキスト

RAG や AI エージェント構築で多くの開発者が直面するのは、function calling(ツール選択・引数生成)と文章生成の役割分担です。

この記事では、function calling には llama3.1:8b を、文章生成には gpt-oss:20b を使う役割分離型の RAG 実装を紹介します。

gpt-oss:20b は高品質な生成に向く一方で function calling には対応していないため、判断と生成を明確に分ける構成を採用します。

Next step として LangChain への移行も見据えつつ、まずは役割分離の高い設計で現実的な開発を進める流れを説明します。

function calling と文章生成の役割分担

前回の記事ではfunction callingに対応したLLMとしてllama3.1:8bを使い、文章生成にも同モデルを用いる構成を紹介しました。

個人のローカル環境でfunction callingを試す場合、事実上llama3.1:8b一択となりますが、日本語生成の品質には物足りなさが残ります。

日本語に定評のあるモデルとして、例えばgpt-oss:20bが挙げられますが、現時点でfunction callingには対応していません。

本記事では、llama3.1:8bはfunction callingを用いた検索に使い、最終的な生成はgpt-oss:20bに分担させる構成を考えます。

分散構成コード例

llama3.1:8bは実用的な構成でVRAM16GB程度が目安とされ、gpt-oss:20bも同程度のVRAMを必要とします。

個人で入手可能なGPUは最大でも24GB程度のVRAMとなるため、両モデルを同時に使うには工夫がいります。ここでは、gpt-oss:20bは別マシンでOllamaサーバを立てて動かす想定としています。

ollamaはモデル切り替えに対応しており、それぞれのモデルを指定すれば同一マシンでも稼働しますが、VRAMが不足したりモデルのロードで時間がかかるため、分散構成の方が安定します。

以下は、function callingをllama3.1:8b、文章生成をgpt-oss:20bに分担させるRAG実装のコード例です。

import ollama
import chromadb
import json
from pathlib import Path
import re

# 生成用 Ollama(別マシンの Ollama を想定)
GENERATION_OLLAMA_HOST = "http://REMOTE_HOST:11434"

FUNCTION_MODEL = "llama3.1:8b"
GENERATION_MODEL = "gpt-oss:20b"
EMBEDDING_MODEL = "nomic-embed-text"

# ChromaDB 初期化
client = chromadb.Client()
collection = client.get_or_create_collection(name="tono_monogatari")

# sample.txt 読み込み(遠野物語)
# 前提フォーマット:
# 【<話番号>:<題目>:L<行番号>】本文テキスト
text_path = Path(__file__).parent / "sample.txt"

documents = []
metadatas = []

header_pattern = re.compile(r"^【(\d+):([^:]+):L(\d+)】\s*(.+)$")

with open(text_path, encoding="utf-8") as f:
    for line in f:
        line = line.strip()
        if not line:
            continue

        m = header_pattern.match(line)
        if not m:
            continue

        section_id = int(m.group(1))
        theme = m.group(2)
        line_no = int(m.group(3))
        text = m.group(4)

        documents.append(text)
        metadatas.append({
            "theme": theme,
            "section_id": section_id,
            "line": line_no
        })

# ドキュメント登録
for i, (doc, meta) in enumerate(zip(documents, metadatas)):
    emb = ollama.embed(
        model=EMBEDDING_MODEL,
        input=doc
    )["embeddings"][0]

    collection.add(
        ids=[str(i)],
        documents=[doc],
        metadatas=[meta],
        embeddings=[emb]
    )

# 検索関数(RAG)
def retrieve_documents(query: str) -> str:
    # 1) クエリEmbedding
    query_emb = ollama.embed(
        model=EMBEDDING_MODEL,
        input=query
    )["embeddings"][0]

    # 2) ベクトル検索
    results = collection.query(
        query_embeddings=[query_emb],
        n_results=3
    )

    if not results["documents"] or not results["documents"][0]:
        return ""
    if not results["metadatas"] or not results["metadatas"][0]:
        return ""

    docs = results["documents"][0]
    metas = results["metadatas"][0]

    # 3) 最上位結果の題目を取得
    main_theme = metas[0].get("theme")

    # 4) 同じ題目のチャンクを追加取得
    same_theme_results = collection.get(
        where={"theme": main_theme}  # type: ignore
    )

    # 5) 重複排除しつつ結合
    all_docs = []
    seen = set()

    for d in docs:
        if d not in seen:
            seen.add(d)
            all_docs.append(d)

    if same_theme_results["documents"]:
        for d in same_theme_results["documents"]:
            if d not in seen:
                seen.add(d)
                all_docs.append(d)

    return "\n".join(all_docs)

# Function 定義(LLMに渡す)
tools = [
    {
        "type": "function",
        "function": {
            "name": "retrieve_documents",
            "description": "遠野物語の文書から、質問に関係する記述を検索する",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "物語内容を検索するためのクエリ"
                    }
                },
                "required": ["query"]
            }
        }
    }
]

# System プロンプト(語り部)
system_prompt = """
あなたは遠野の里に古くから伝わる物語を語り継ぐ語り部です。

学術的・説明的・百科事典的な文章を書いてはいけません。
「〜とは」「〜である」といった解説調は禁止です。

必ず、囲炉裏端で昔話を聞かせるように、
聞き手に語りかける口調で語ってください。
事実を列挙するのではなく、「語り」として話してください。

語りの中では、次のような言い回しを自然に使って構いません。
「〜と伝えられておる」
「〜と人は申す」
「里ではそう語られてきた」
「確かなことは分からぬが」

ただし、語る内容は retrieve_documents 関数によって取得された
検索結果に含まれる事実の範囲に限定してください。
検索結果にない出来事・設定・結末を想像で補ってはいけません。

語りは、必ず次の順で構成してください。
1. 聞き手に向けた語りかけの導入
2. 検索結果に基づく物語の語り
3. 断定を避け、余韻を残す締め
"""

# ユーザー入力
user_question = "なにか知っている話について教えてください"

# 1回目:LLMに検索判断させる
response = ollama.chat(
    model=FUNCTION_MODEL,
    messages=[
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_question}
    ],
    tools=tools
)

message = response["message"]

# Function Calling 判定
if "tool_calls" not in message:
    raise RuntimeError("LLMが検索を実行しませんでした")

tool_call = message["tool_calls"][0]
args = tool_call["function"]["arguments"]

# 検索実行(アプリ側)
context = retrieve_documents(args["query"])

# 2回目:検索結果を渡して文章生成(gpt-oss:20b / 別マシン)
generation_client = ollama.Client(host=GENERATION_OLLAMA_HOST)
final_response = generation_client.chat(
    model=GENERATION_MODEL,
    messages=[
        {"role": "system", "content": system_prompt},
        {
            "role": "system",
            "content": (
                "以下は retrieve_documents で取得した検索結果です。\n"
                "検索結果はあなた自身の知識として、含まれる事実の範囲に限定して利用してください。\n"
                "想像による出来事・設定・結末を付け足してはいけません。\n"
                f"{context}"
            )
        },
        {"role": "user", "content": user_question}
    ]
)
print("質問:", user_question)
print("回答:", final_response["message"]["content"])

出力例:

質問: なにか知っている話について教えてください
回答: やぁ、今宵も焚き火の灯火を囲むてい、古びた語り部の声を聞かんか。遠野の風を胸に抱いて、聞く者の耳にほんやかに語りかける。  

さて、私が語りたがる話は、遠野郷の山間に住む者たちが長く受け継いできた祭りのことだ。これを聞いたのは、佐々木鏡石君という誠実な方からである。彼は、明治四十二年の二月頃から夜分に訪ねて来て、語りを紙に綴ってくれたという。  

その話の中で、天神の山に春の訪れとともに催される祭りが語られた。山に響く笛の調子は高く、歌は低くなっていたと言う。そこに現れたのは、鹿の角を被った面を付けた童子五六人。剣を抜き、舞い踊るその姿は、獅子踊りの舞であると人は申す。獅子踊りは、実は鹿の舞で、町の緑に映る紅き物が少し閃き、尘が軽く舞い散る光景が見た目に鮮烈だった。  

夜が更けると、風が吹き酔いしれ、旅人の声も寂しくなる。そんな中、女は笑い、児は走るが、旅愁をいかんともするものの、祭りの熱は凛と続いた。  

また、遠野郷には八ヶ所に観音堂があるという語りも伝えられている。木一枚で作られた観音堂は、静かに人々の祈りを受け止めている。  

そして、この物語の終りは、いつも「コレデドンドハレ」と結ばれた。語りの輪が閉じるその瞬間に、焚き火の炎がさらに一層、柔らかな光を放ち、夜の静寂を満たす。  

確かなことは分からぬが、遠野の人々の心の中には、こうした祭りと祈りが根付き、長い年月を越えて語り継がれ続けていると、里ではそう語られてきた。今宵の火の音に耳を澄ませ、遠野の風を感じてみたら、誰もがその静かな物語に触れたことを思い出すだろう。

まだ日本語は完璧ではありませんが、文章としての構成は明らかに質が向上しました。 言葉遣いはプロンプト改善でさらに精度を高められるでしょう。

別マシンで生成だけ担わせる構成

以下は、function calling を llama3.1:8b、
文章生成を gpt-oss:20b が担当する基本的なフローの例です。

1. 判断・構造化(Function calling)

クライアントからの質問を受け取ると、まず llama3.1:8b で function calling を行います。
Function calling は JSON 形式で返ってくる判断情報であり、

  • どのツールを呼ぶべきか
  • どのパラメータを使うか

を明確にします。

2. ツール・検索処理

Function calling で出力されたツール名と引数をもとに、Vector DB や外部 API、Web 検索などを実行します。

3. 生成(最終回答)

ツール結果や検索コンテキストをまとめた上で、生成モデル(例:専用サーバやクラウドの gpt 系など)へ引き渡して文章を生成させます。

このように役割を分離することで、判断はローカル・生成は高品質モデルといったハイブリッド構成が実現できます。

実装上のポイント

HTTP API で分散構成にする

Ollama は標準で OpenAI 互換の API を提供するため、llama3.1:8b を別マシンで動かしつつ、他の生成モデルとネットワーク越しに連携できます。
たとえば、Python 側で以下のようなエンドポイント構成を作れます。

function_response = request_to_ollama('/v1/chat/completions', model='llama3.1:8b')
…
final_answer = request_to_external_model('/generate', model='gpt-oss:20b', context=context)

このように API ベースで役割を分離すると、後で LangChain や Agent フレームワークへ統合する際も自然な流れになります。

役割分担のメリット

  • モデル負荷の分散: 20B は構造化・判断、生成はより高性能モデルへ
  • 独立性: 部分ごとに差し替えやアップデートが容易
  • 保守性向上: 生成モデルだけをクラウドで単独バージョンアップ可能

まとめ

今回の構成は function calling と生成を分離するための暫定設計です。

このように、function calling を llama3.1:8b、文章生成を gpt-oss:20b に分離することで、モデル特性を活かした現実的な RAG 構成が実現できます。

本格的な拡張に備え、次回はこの構成を LangChain で整理し、チェーン・Retrieval・ツール管理を統一する記事へつなげることを想定しています。

LangChain は 複数モデルを統合し、役割ごとに切り離された構成を管理する API を提供しますので、今回の設計をそのまま次ステップに持ち越せます。

タイトルとURLをコピーしました