LLM アプリ開発 #13 実践プロジェクト: 社内文書 Q&A ボット

読了 4分

最後の記事です。これまで扱ったかけらを一つに束ねて、社内文書に答える Q&A ボットを作ってみます。会社の規定や製品マニュアルを入れておけば、社員が自然言語で尋ね、文書に基づく答えを受け取れるアプリです。この一つのアプリに、シリーズの核心がほぼすべて入ります。

何を束ねるか #

このボットは、これまで作ったかけらの組み合わせです。

  • 検索 — 文書をチャンクに分けて埋め込み、質問に近いチャンクを見つけます(第7回第8回)。
  • 根拠ある答え — 見つけたチャンクだけを根拠に答えさせ、なければ分からないと答えさせます(第4回)。
  • ストリーミング — 答えをリアルタイムで流します(第3回)。
  • 会話メモリ — 続く質問のために履歴を保ちます(第2回第9回)。

準備: 文書をインデックスする #

まず文書をチャンクに分けて埋め込んでおきます。このインデックスは文書が変わったときだけ行えばよく、質問のたびにやり直しません。

qa_index.py
import numpy as np
from sentence_transformers import SentenceTransformer

embedder = SentenceTransformer("all-MiniLM-L6-v2")

def chunk_text(text: str, size: int = 300, overlap: int = 50):
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start:start + size])
        start += size - overlap
    return chunks

# 社内文書をチャンクに分けて埋め込む
documents = [open(f).read() for f in ["policy.txt", "manual.txt"]]
chunks = [c for doc in documents for c in chunk_text(doc)]
chunk_vectors = embedder.encode(chunks)

def retrieve(query: str, top_k: int = 3):
    q = embedder.encode([query])[0]
    scores = chunk_vectors @ q
    ranked = np.argsort(scores)[::-1][:top_k]
    return [chunks[i] for i in ranked]

ボットを作る #

検索、根拠プロンプト、ストリーミング、メモリを一つの関数に束ねます。

qa_bot.py
import anthropic

client = anthropic.Anthropic()
messages = []  # 会話メモリ

SYSTEM = "あなたは社内文書を案内するアシスタントです。提供された資料だけを根拠に答え、資料になければ「資料からは見つかりません」と答えてください。"

def ask(question: str) -> None:
    # 1) 検索: 質問に関連するチャンクを見つける
    found = retrieve(question)
    context = "\n\n".join(f"<doc>{c}</doc>" for c in found)

    # 2) 根拠プロンプト: 資料と質問を一緒に入れる
    user_content = f"<資料>\n{context}\n</資料>\n\n質問: {question}"
    messages.append({"role": "user", "content": user_content})

    # 3) ストリーミング: 答えをリアルタイムで出力しながら集める
    answer = ""
    with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=SYSTEM,
        messages=messages,
    ) as stream:
        for text in stream.text_stream:
            print(text, end="", flush=True)
            answer += text
    print()

    # 4) メモリ: 答えを履歴に足す
    messages.append({"role": "assistant", "content": answer})

# 対話的に使う
ask("年次休暇は何日ですか?")
ask("それを半休に分けて使えますか?")  # これまでの文脈を覚えて続けて答える

流れを追ってみます。質問が来ると、まず関連するチャンクを検索し(1)、その資料と質問を一緒にプロンプトへ入れます(2)。system プロンプトで「資料だけを根拠に、なければ分からない」というルールを固定して幻覚を防ぎます。答えはストリーミングでリアルタイムに出力しながら同時に集め(3)、終わったら履歴へ足して次の質問が文脈を継ぐようにします(4)。二つ目の質問「それを半休に」の「それ」が年次休暇を指すとボットが分かる理由が、このメモリです。

ここからさらに進めるには #

このボットは核心の構造をすべて備えていますが、実際のサービスへ進めるには、これまで扱ったものをさらに付け足します。

  • コスト — system と資料が繰り返されるなら、プロンプトキャッシュで費用を減らします(第12回)。会話が長くなれば履歴を要約して圧縮します(第9回)。
  • 規模 — 文書が多くなれば、全体比較の代わりにベクトルデータベースを使います(第7回)。
  • 品質 — 質問と期待する答えの評価セットで、プロンプト変更の効果を測ります(第12回)。
  • Web — コンソール出力の代わりに StreamingResponse でブラウザまで答えを流します(第3回)。
  • 行動の拡張 — 文書検索を越えて外部システムを操作する必要があれば、ツール呼び出しとエージェントへ広げます(第6回第10回)。

シリーズを終えて #

最初に始めたときは、API キー一つで一文をやり取りするのが全部でした。そこからメッセージの構造とパラメータ、ストリーミング、プロンプト、構造化された出力で基礎を固め、ツール呼び出しと RAG、メモリ、エージェント、MCP でアプリが外の世界とつながるようにし、コストと評価と観測で運用の土台を整えました。そして今回、そのすべてを一つの動くアプリへ束ねました。

LLM アプリ開発の全体像は、じつは単純です。良い文脈を集めてプロンプトに入れ、モデルの答えを受け取ってコードへ安全につなぐこと。検索も、メモリも、構造化された出力も、結局はこの大きな流れの一部でした。このシリーズで身につけたかけらを組み合わせれば、あなたの問題に合った LLM アプリを自分で設計できます。

このシリーズは Claude を基準に説明しましたが、扱ってきた発想はほとんど他のプロバイダにもそのまま移せます。さあ、作りたいアプリを決めて、最も小さい形からかけらを一つずつ足していってみてください。ここまでご一緒いただき、ありがとうございました。

X