2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ollama + RAGで社内文書Q&Aボットを構築した — 埋め込みモデル3種比較と「答えない」設計

2
Posted at

管理部で仕事をしていると、「結婚したら何日休めますか?」「出張の宿泊費、上限いくらですか?」みたいな質問が繰り返し来ます。答えは全部、就業規則や経費精算規程に書いてある。でもみんな探すのが面倒で聞いてくる。

これをAIに任せたかったんですが、社内規程にはそれなりに機微な情報が含まれています。クラウドのAIサービスに送るのは避けたい。

そこで、Ollama(ローカルLLM)+ RAG + LangChain で、少なくともクラウドにデータを送信しない構成の社内文書Q&Aボットを作りました。ただし、ローカル構成であっても端末管理やベクトルDBへのアクセス制御は別途必要です。この記事では「クラウドに社内文書を送らない」構成を目指しています。

この記事では、実装手順と、精度に直結した埋め込みモデル3種の比較結果、そして実運用で採用した数値を答えないプロンプト設計について書きます。

構成概要

03_architecture.png

レイヤー 技術 役割
LLM Ollama qwen3:14b 回答生成(temperature=0)
埋め込みモデル bge-m3 質問と文書の意味的マッチング
ベクトルDB ChromaDB 埋め込みベクトルの保存・検索
RAGフレームワーク LangChain 検索→プロンプト注入→回答のチェーン
フロントエンド Streamlit チャットUI

環境

  • Python 3.12
  • Ollama(RTX 4090で稼働)
  • 必要パッケージ:
langchain-ollama
langchain-community
langchain-text-splitters
chromadb
streamlit

プロジェクト構成

project/
├── docs/              # 社内文書(Markdown)
├── src/
│   ├── config.py      # 設定(モデル名・チャンクサイズ等)
│   ├── ingest.py      # 文書取り込み+ベクトル化
│   ├── rag.py         # RAGチェーン+回答生成
│   └── app.py         # Streamlit UI
└── requirements.txt

設定値

# config.py
OLLAMA_BASE_URL = "http://localhost:11434"
LLM_MODEL = "qwen3:14b"
EMBEDDING_MODEL = "bge-m3"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
SEARCH_K = 10

セットアップ

# Ollamaに必要なモデルをpull
ollama pull qwen3:14b
ollama pull bge-m3

# 依存パッケージのインストール
pip install -r requirements.txt

# 文書の取り込み(初回のみ)
cd src
python ingest.py

# アプリ起動
streamlit run app.py

Note: LangChain周辺はパッケージ分割の変更が多いため、記事内のコードは執筆時点のバージョンに合わせています。再現時は requirements.txt のバージョン固定を推奨します。また、Chromaは langchain-chroma パッケージに移行が進んでいます。環境によっては from langchain_chroma import Chroma に置き換えてください。

ステップ1: 社内文書の取り込み

文書の準備

今回はダミーの社内文書(就業規則・経費精算規程・出張旅費規程・情報セキュリティポリシー)をMarkdownで4本用意しました。実際の社内文書は使えないので、管理部あるあるの内容で作成しています。

チャンク分割

文書をそのままベクトル化すると、1ファイルが長すぎて検索精度が落ちます。適切な長さに分割(チャンキング)する必要があります。

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    separators=["\n## ", "\n### ", "\n\n", "\n", "", "", " "],
)
chunks = splitter.split_documents(documents)

ポイントは separators の順序です。Markdownの見出し(#####)を優先的に区切りに使い、それでも chunk_size を超える場合は段落→文→句点の順で分割します。日本語文書では を入れておくと、変な位置で切れにくくなります。

chunk_overlap=200 は、チャンクの前後200文字を重複させる設定です。これで分割の境目にある情報が欠落するのを防ぎます。

ベクトル化とDB格納

分割したチャンクを埋め込みモデルでベクトル化し、ChromaDBに保存します。

from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import Chroma

embeddings = OllamaEmbeddings(
    base_url="http://localhost:11434",
    model="bge-m3",
)

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
)

これで取り込み完了です。一度実行すれば chroma_db/ にデータが永続化されるので、毎回取り込み直す必要はありません。

ステップ2: 埋め込みモデル比較 — ここが精度の分かれ目

今回の構成では、回答品質への影響はLLM本体より埋め込みモデルの方が大きく出ました。

「結婚したら何日休めますか?」と聞かれたとき、就業規則の「慶弔休暇」の条文を引っ張ってこれるかどうかは、このモデルの性能にかかっています。「結婚」と「慶弔休暇」が意味的に近いと判断できるかどうか、という話です。

3つのモデルで同じ8問を試しました。比較では、文書セット・チャンク設定(chunk_size=1000, overlap=200)・retrieverのk・生成モデル・プロンプトを固定し、埋め込みモデルのみを差し替えています。正答判定は人手で実施しました。

比較結果

モデル パラメータ 最大入力長 正答率 問題点
nomic-embed-text 137M 8192 37.5%(3/8) 英語向け。日本語の「結婚」↔「慶弔休暇」を関連付けられない
mxbai-embed-large 335M 512 50%(4/8) 最大入力長512。日本語は1文字あたりの情報量が多く、チャンクがはみ出す
bge-m3 568M 8192 100%(8/8) なし。多言語対応で日本語に強い

埋め込みモデル名以外の条件は固定したまま比較しました。

日本語の社内文書でRAGを組むなら、多言語対応かつ最大入力長が十分なモデルを選ぶのが重要です。少なくとも今回の検証では、bge-m3が最も安定していました。568Mとサイズは大きめですが、精度の差を考えると迷う余地はありませんでした。

ステップ3: RAGチェーンの構築

LangChainで、検索→整形→プロンプト注入→LLM→出力パースのチェーンを組みます。

from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

def create_rag_chain():
    vectorstore = get_vectorstore()
    retriever = vectorstore.as_retriever(search_kwargs={"k": 10})

    llm = ChatOllama(
        base_url="http://localhost:11434",
        model="qwen3:14b",
        temperature=0,
    )

    prompt = ChatPromptTemplate.from_messages([
        ("system", SYSTEM_PROMPT),
        ("human", "{question}"),
    ])

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )

    return chain, retriever

search_kwargs={"k": 10} は、質問に対して上位10チャンクを検索する設定です。最初は6で試していましたが、複数の規程を横断する質問(「海外出張に必要な手続きを全部教えて」等)に対応するため10に増やしました。

検索結果の整形は、出典情報を付与してLLMに渡します:

def format_docs(docs):
    formatted = []
    for doc in docs:
        source = doc.metadata.get("source", "不明")
        formatted.append(f"[出典: {source}]\n{doc.page_content}")
    return "\n\n---\n\n".join(formatted)

ステップ4: プロンプト設計 — 「数値を答えない」ルール

ここが今回一番苦労したところです。

埋め込みモデルをbge-m3に決めた後、本番を想定した20問テストを実施しました。そこで出た問題がプロンプト設計の見直しにつながりました。

20問の精度検証で80%正解だったんですが、間違えた内容が致命的でした。「配偶者の父が亡くなったら何日休めますか?」に対して「5日」と答えたんですが、正解は3日。就業規則の特別休暇テーブルで「父母・配偶者・子の死亡=5日」と「配偶者の父母の死亡=3日」を混同していました。

慶弔休暇の日数を間違えたら、シフトも給与計算も狂います。管理部的にはこれは「まあ仕方ないね」では済まない。

最終的に、具体的な数値(金額・日数・期限など)は一切出さず、該当する規程の条文を案内する設計にしました。

SYSTEM_PROMPT = """\
あなたはサンプル株式会社の社内アシスタントです。
以下の社内文書の内容に基づいて、従業員からの質問に回答してください。

ルール:
- 質問に対して、該当する制度・手続きの概要を説明すること
- 必要な手続き・条件・ステップが複数ある場合は、
  文書に記載されている項目をすべて個別にリストアップすること。
  「その他」などにまとめず、省略しないこと
- 【最重要・厳守】具体的な数値(金額・日数・期限・上限額・回数・割合など)は
  絶対に回答に含めないこと。
  数値の代わりに「具体的な日数については○○規程 第○条をご確認ください」
  と規程を案内すること
  - 悪い例:「特別休暇として5日間取得可能です」
  - 良い例:「特別休暇を取得可能です。具体的な日数については
    就業規則 第8条をご確認ください」
- 社内文書に書かれている情報のみに基づいて回答すること
- 文書に記載がない場合は
  「社内文書に該当する記載が見つかりませんでした」と回答すること
- 回答の末尾に
  「※本回答は参考情報です。正確な内容は必ず原本をご確認ください。」を付けること

参照する社内文書:
{context}
"""

最初は「数値は出すな」と一行書いただけだったんですが、LLMは無視して「5日間です!」と元気に答えてきました。最終的に、良い例・悪い例を具体的に書いて、さらに回答直前にセルフチェック指示を入れて、やっとほぼ守ってくれるようになりました。

ただし、プロンプトだけでは100%防げません。質問によっては数値が漏れます。そこで、コード側でも後処理を入れています。

import re

def sanitize_numbers(text: str) -> str:
    """条文番号(第○条/第○章)以外の数値を伏せる"""
    # 条文番号・章番号を一時退避
    articles = re.findall(r"第\d+[条章]", text)
    text = re.sub(r"第\d+[条章]", "__ART__", text)

    # 数値を含む表現を置換
    text = re.sub(r"\d[\d,]*\s*円[//][日月年]", "規程に定められた額", text)
    text = re.sub(r"\d[\d,]*\s*円", "規程に定められた額", text)
    text = re.sub(r"\d+\s*日間", "所定の日数", text)
    text = re.sub(r"\d+\s*営業日以内", "所定の期限内", text)
    text = re.sub(r"\d+\s*営業日", "所定の期限", text)

    # 条文番号を復元
    for article in articles:
        text = text.replace("__ART__", article, 1)
    return text

プロンプトで9割防ぎ、残りの1割をコードで潰す。両方やって初めて実用レベルになりました。

回答例:

02_overseas_trip.png

「答えを出さない」のは一見もったいないですが、規程内の網羅的な検索には非常に有効です。「海外出張に必要な手続きを全部教えて」と聞くと、出張旅費規程の中から承認・パスポート・保険・予防接種・通信費・外貨・報告書・キャンセルまで一括でリストアップしてくれます。人間がやると規程を全部読んで拾い上げる作業が必要ですが、それが一発で済みます。

ステップ5: Streamlit UIの実装

UIは最小構成ですが、実運用を意識して「参照文書の確認」と「フィードバック機能」を入れています。

import streamlit as st
from rag import ask

st.set_page_config(page_title="社内文書Q&A", page_icon="📋", layout="wide")
st.title("📋 社内文書Q&A チャットボット")

# サイドバーで参照文書の表示切替
with st.sidebar:
    show_sources = st.toggle("参照文書を表示", value=False)

# チャット履歴
if "messages" not in st.session_state:
    st.session_state.messages = []

# 履歴表示
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# ユーザー入力
if prompt := st.chat_input("質問を入力してください"):
    st.session_state.messages.append({"role": "user", "content": prompt})
    result = ask(prompt)
    st.session_state.messages.append({
        "role": "assistant",
        "content": result["answer"],
        "sources": result["sources"],
    })
    st.rerun()

フィードバック機能では、回答後に「解決しましたか?」と聞いて、「いいえ」なら検索結果から関連トピックを3件抽出して表示します。関連トピックは、検索で引っかかったチャンクのMarkdown見出し(## / ###)から抽出し、回答に使われた条文は除外しています。

# フィードバック(最後のアシスタントメッセージに表示)
if feedback is None and related:
    st.markdown("**この回答で解決しましたか?**")
    col1, col2, col3 = st.columns([1, 1, 6])
    with col1:
        if st.button("はい", key=f"yes_{i}"):
            message["feedback"] = "resolved"
            st.rerun()
    with col2:
        if st.button("いいえ", key=f"no_{i}"):
            message["feedback"] = "unresolved"
            st.rerun()

elif feedback == "unresolved" and related:
    st.markdown("**関連するトピックはこちらです:**")
    for j, rq in enumerate(related):
        if st.button(f"💬 {rq}", key=f"related_{i}_{j}"):
            st.session_state.pending_question = rq
            st.rerun()

関連トピックのボタンをクリックすると pending_question にセットされ、次のループでそのまま ask() が実行される仕組みです。ユーザーが「いいえ」を押した後に自分で質問を考え直す手間を省けます。

精度検証: 20問テスト

bge-m3で埋め込み、プロンプトを調整した最終版で、本番想定の20問を実行しました。

難易度 問題数 正答 不十分 不正確 正答率
簡単(文書に直接記載) 8 8 0 0 100%
普通(少し読み解きが必要) 8 5 1 2 62.5%
難しい(複数規程を横断) 4 3 1 0 75%
合計 20 16 2 2 80%

「普通」の不正確2問は、いずれも似た条文の混同(前述の慶弔休暇の例)。「数値を出さない」設計にしたことで、この種の誤答が実務上の問題になることは防げています。

まとめ

学び 詳細
埋め込みモデルが最重要 日本語文書では多言語対応モデルが有利。今回の検証ではbge-m3が最も安定(37.5%→100%)
LLMの精度だけでは不十分 20問テストで80%正解でも、残り20%の誤答が致命的になりうる
「答えない」設計が有効 数値を出さず規程を案内する方が安全。横断検索では十分な価値がある
プロンプトは具体例が必要 「数値を出すな」だけでは守らない。良い例・悪い例を書いて初めて機能する

全コード(約400行)で動く構成なので、社内で「AIチャットボットを試したい」という相談が来たときの叩き台にはなると思います。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?