Ollama×ChromaDBで「語り部」を作る:RAGにより「遠野物語」をAIに語らせる実装ポイント

AIテキスト

この記事では、一連のRAGを扱う記事のまとめとして、Ollamaの埋め込み機能とChromaDBのベクトル検索、さらにFunction Callingを組み合わせたRAG構成を例に実装を行います。

前回の記事で取り扱った「遠野物語」を題材に、「語り部」のような語り口を維持しつつ検索精度を高める実装ポイントを整理します。

特に、ベクトル検索で得られた最上位結果の題目に属するチャンクを追加して文脈を補強する手法に注目し、その効果と注意点を解説します。

RAGの実装例:二段階検索で情報を補完

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

# 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
        })

# Chroma DBへのドキュメント登録
for i, (doc, meta) in enumerate(zip(documents, metadatas)):
    emb = ollama.embed(
        model="nomic-embed-text",
        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="nomic-embed-text",
        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 ""

    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}
    )

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

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

    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="llama3.1:8b",
    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回目:検索結果を渡して回答生成
final_response = ollama.chat(
    model="llama3.1:8b",
    messages=[
        {"role": "system", "content": system_prompt},
        {
            "role": "system",
            "content": (
                "以下は retrieve_documents で取得した検索結果です。\n"
                "検索結果はあなた自身の知識として、含まれる事実の範囲に限定して利用してください。\n"
                "想像による出来事・設定・結末を付け足してはいけません。\n"
                f"{context}"
            )
        },
        {"role": "user", "content": user_question}
    ],
    tools=[]
)
print("質問:", user_question)
print("回答:", final_response["message"]["content"])

前回までシンプルなRAGを実装しましたが、今回はもう一つ工夫を加えています。

本コードの処理は大きく二段階に分かれています。最初に、ユーザーの質問を受け取ったLLMが検索の必要性を判断し、必要に応じて retrieve_documents 関数を呼び出します。

次に、アプリケーション側でChromaDBから関連する文書チャンクを取得し、それらを文脈としてまとめたうえで、再度LLMに渡して最終的な回答を生成します。

「題目で同じチャンクを追加」が効く理由

この実装の特徴は、通常のベクトル検索で上位にヒットしたチャンクだけでなく、その最上位結果が属する題目を取得し、同じ題目に分類されているチャンクを追加で文脈に含めている点です。

これにより、質問が物語の特定の場面や話題に強く関連している場合、その周辺にある記述も同時に参照されやすくなります。

短い断片のみを渡した場合に比べ、同一題目内の情報をまとめて扱うことで、断片的な説明に寄らず、物語としての流れを保った回答を生成しやすくなります。特に「語り部」のような語り口を狙う場合、この文脈の厚みが効果的に働きます。

一方で、題目に含まれる範囲が広い場合、関連性の低いチャンクまで混ざる可能性があります。その結果、回答の焦点がぼやけ、語り口が説明的に傾くことがあります。この点は後述する注意点として重要です。

出力例

下記は実行例で、llama3.1:8bモデルを用いて実行した際の出力です。 LLMの特性として、出力は実行の都度変わります。本語や表現に違和感はありますがまずまずの精度です。

質問: なにか知っている話について教えてください
回答: 聞き手さん!遠野の里では、この辺に伝わる古い物語を今でも語り継いでいますよ。

遠野の町の中で、池端という家の先代の主人が宮古に行って帰ってきたとき、川原台の淵近くで若い女が手紙をくれたそうです。すると、その人の言っておることによると、沼へ行くと手を叩けば宛名の人が出てくるものなのにぃ。

私はその人に請け合いをしたと言いますけれども、心を掛けて旅を続けていたら、一人の六部に行き逢ったそうです。手紙を開いて読み、こんなことを言っておるという。そして書き換えて取らすべしと言って、あとに別の手紙をくれたそうです。

その後、その人持って沼へ行き、教えのように手を叩いたところ、若い女性が出てきて手紙を受け取り、その礼として小さな石臼をくれたそうです。それで、米を一粒入れて回せば下から黄金が出るってのことです。

それでその家はやや富むようになって、妻が欲深く大量の米を掬い入れました。すると石臼はしきりに自ら回り、それに続いて朝ごとに主人が供える水が石臼の中の窪みの中に溜まり、そこへ滑り込み見えなくなってしまったそうです。

その後、その家は小さな池になって、今も家の近くにあるということです。それで家の名を池の端といわれていると言われています。

まとめ

本記事では、OllamaとChromaDBを直接組み合わせたシンプルなRAG構成を通して、仕組みを理解しやすい形で「語り部RAG」の実装例を紹介しました。

特に、検索結果の題目に基づいて関連チャンクを追加する工夫は、物語としての流れを保ちながら回答精度を高めるうえで有効です。

今後、構成の再利用や拡張を重視する段階では、LangChainのようなフレームワークを検討する余地もありますが、まずは本記事のようなシンプルなの実装で挙動を把握することが重要です。

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