RAG で "ブラック企業社内規則チャットボット" を作ってみた
はじめに
本記事では Retrieval-Augmented Generation(RAG)の勉強・技術検証を目的に,「ブラック企業の社内規則」を題材としたチャットボットを作成しました.
その内容は完全なフィクションであり,実在の企業・団体・人物とは一切関係ありません.
もしこのような表現が苦手な方は,閲覧にご注意ください.
とはいえ,前半部分は RAG の説明,後半部分にデモがあるので,デモを避けていただければと思います.
本記事で使用したコードは,以下のリポジトリにて公開しています.
🔗 GitHub:rag-sample-practice
今回使用した構成やデモコードは,完全ローカル環境かつ軽量で
CPU で動作可能な構成を意識して実装しています.
RAG とは
まず,RAG の簡単な説明です.
目的
RAG は Large Language Model(LLM)を再学習せずに,新しいデータの情報や手持ちのデータを回答へ反映させるための仕組みです.
世の中の LLM は大規模なデータと計算リソースを用いて学習されています.そのため,一個人や企業でイチから学習したり再学習を行うのは現実的ではありません.
しかし,LLM が学習していな情報を元に質問したい場面は多くあります.
そのようなときに RAG を活用することで,LLM に外部の情報を与えながら回答を得ることが可能になります.
活用例
- ✅ 社内ナレッジ検索
→ 社員マニュアルや FAQ をもとに社内 Bot を作る - ✅ 最新情報を用いたチャット
→ 法改正・論文・時事ニュースなどで最新の回答を得たい
これらは一般的な LLM には含まれていない情報のため,通常の対話では誤った回答(ハルシネーション) を引き起こす可能性があります.
RAG を使うことで,それを防ぎつつ正確な知識に基づいた回答が可能になります.
仕組み
RAG の基本的な仕組みは下記です.
データの準備
- 回答の元となる外部情報(ドキュメントなど) を用意する
- それらの情報を ベクトル(数値表現)に変換し,ベクトルデータベースに登録しておく
質問と回答の流れ
- ユーザからの質問内容もベクトルに変換する
- 変換したベクトルを用いて,ベクトルデータベースから質問内容と類似した文書を検索
- 検索したデータ・情報を LLM へ渡すプロンプトに組み込む
- LLM が,追加された情報を活用して回答を生成する
今回はこの仕組みを実装してみました.
今回の構成
本構成では,GPU 不要かつローカルで軽快に動作する技術を中心に選定しました.
使用した主要なコンポーネントは以下の通りです.
使用技術
名前 | 説明 | |
---|---|---|
LLM モデル | Phi-3-mini-4k-instruct-q4.gguf | Microsoft が提供する LLM ビット精度 4bit で軽量 |
埋め込みモデル | sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 | 文書を特定次元の特徴ベクトルに落とし込むモデル 日本語対応でそれなりに高精度 |
ベクトル検索ライブラリ | FAISS | 類似ベクトルを高速に検索するためのライブラリ Meta が開発 |
LLM 推論ライブラリ | llama-cpp-python(llama_cpp) | gguf モデルが動作可能な LLM 推論ライブラリ Python から簡単にローカル LLM を呼び出せる |
先ほどの図で,技術を色分けすると下図になります.
コード解説
ここからは前述技術を用いたコードの説明をします.
(再掲)本記事で使用したコードは,以下のリポジトリにて公開しています.
🔗 GitHub:rag-sample-practice
ベクトル DB の作成
使用するコードはこちら.
外部ドキュメントの読み込み
from sentence_transformers import SentenceTransformer
import faiss
from config import config
from utils import get_logger, load_documents
logger = get_logger()
logger.info("Loadindg documents")
cat_documents = load_documents(json_path=config["DOC_PATH"])
-
load_documetns()
関数で指定した JSON ファイルを parse - JSON の形式は
{"title": タイトル, "content": 内容}
を想定
→ サンプルはこちら - 関数の返り値は以下のような文字列のリスト
["タイトル1:内容1", "タイトル2:内容2", ...]
- これが「RAG で使用する外部情報の元データ」となります
文字列のベクトルへの変換
logger.info("Creating embedder")
embedder = SentenceTransformer(config["EMBED_MODEL"])
doc_embeddings = embedder.encode(cat_documents)
-
SentenceTransformer()
により,埋め込み器を初期化 - 引数には使用するモデルを指定
-
.encode()
を使って,外部文書(文字列)を数値ベクトルに変換
→ 本構成では,384 次元のベクトルに変換
ベクトル DB の作成と保存
dim = doc_embeddings[0].shape[0]
index = faiss.IndexFlatL2(dim)
index.add(doc_embeddings)
faiss.write_index(index, config["INDEX_PATH"])
logger.info(f"✅ FAISS Index Saved! {config['INDEX_PATH']}")
-
faiss.IndexFlatL2()
により,L2 距離ベースのインデックスを作成
→ これが「ベクトル検索用の DB」になります
→ 検索時に L2 距離で検索します -
.add()
で埋め込み済みのベクトルをインデックスに登録 -
faiss.write_index()
により,ベクトル DB をファイルとして保存
→ このファイルは検索時に使用します
これで,外部情報のベクトルの保存が完了しました.
問い合わせ
使用するコードはこちら.
準備
logger = get_logger()
logger.info("📦 Loading Index and Document...")
index = faiss.read_index(config["INDEX_PATH"])
cat_documents = load_documents(json_path=config["DOC_PATH"])
embedder = SentenceTransformer(config["EMBED_MODEL"])
logger.info("📦 Crating LLM Model...")
llm = Llama(
model_path=config["MODEL_PATH"],
n_ctx=2048,
n_threads=4,
)
- ここでは各種準備をしている
-
faiss.read_index()
で先ほど作成したインデックスの読み込み -
load_documents()
で,埋め込む前の外部情報を取得 -
SentencTransformer()
で組み込み器の初期化 -
Llama()
で LLM の準備
→n_ctx
は最大のトークン数,n_threads
は推論時のスレッド数
インデックスに対する検索
query = "社内規則に関して,朝の挨拶のルールを教えてください"
# query = "朝の挨拶ってどこまで丁寧にしたほうがいいですか?"
# query = "水を飲みに行ってきてもいいですか?"
# query = "今日、定時で帰っていいですか?"
query_vec = embedder.encode([query])
distance, indices = index.search(query_vec, config["TOP_K"])
context_chunks = [cat_documents[i] for i in indices[0]]
context_text = "\n\n".join(context_chunks)
- 問い合わせ内容を
query
で指定 -
embedder.encode([query])
により,問い合わせ内容(文字列)を数値ベクトル化 - index.search() を使用して,問い合わせ内容(ベクトル)と類似度の高い外部情報の番号(indices) を取得
→ ここで,TOP_K は上位 K 個まで取得を表す
→ 問い合わせ内容と類似度の高い外部情報は問い合わせ内容の答えとなる可能性が高い - 取得した indices を用いて,ベクトルに変換する前の外部情報の文字列を取得
- コンテキスト(前提情報)作成
RAG あり/なしでの問い合わせ
logger.info("[RAG なし] 推論中...")
prompt_wo_RAG = make_prompt("", query)
res_wo_RAG = llm(prompt_wo_RAG, max_tokens=256, stop=["<|end|>", "<|user|>", "<|assistant|>"])
answer_wo_RAG = res_wo_RAG["choices"][0]["text"].strip()
logger.info("[RAG あり] 推論中...")
prompt_w_RAG = make_prompt(context_text, query)
res_w_RAG = llm(prompt_w_RAG, max_tokens=256, stop=["<|end|>", "<|user|>", "<|assistant|>"])
answer_w_RAG = res_w_RAG["choices"][0]["text"].strip()
logger.info("[RAG なし] answer")
print(answer_wo_RAG)
print()
logger.info("[RAG あり] answer")
print(answer_w_RAG)
-
make_prompt() により,外部情報と問い合わせ内容を組み合わせたプロンプトを作成
→ RAG ではコンテキスト文脈を LLM のプロンプトに手動で注入する必要があるため、このような関数を使って文字列を組み立てる -
make_prompt() は下記のフォーマットの文字列を返す
→context
に「取得した外部情報」,query
に「問い合わせ内容」が入る
→<|user|>
,<|end|>
,<|assistant|>
は Phi のプロンプトの構文( "<|user|>\n" "社内ルールに載っていることのみに基づいて答えてください." f"社内ルール:{context}\n\n" f"質問:{query}\n" "<|end|>\n" "<|assistant|>\n" )
-
RAG なしの場合は,第一引数に "" を指定
-
RAG ありの場合は,第一引数に取得したコンテキストを指定
-
llm()
で推論の実行
→ 第一引数でプロンプト,max_tokens
でトークンの長さ,stop
には LLM が出力を続けすぎないように終端トークンを指定 -
LLM による回答を出力する
デモ
今回,ブラック企業の社内ルールを題材に,社内ルール問い合わせチャットボットを作成してみました.
社内ルールを外部情報として,LLM に回答をしてもらうツールです.
今回使用した社内ルールの全体はこちらを参照ください.
例としては下記のようなものがあります.
{
"title": "トイレ利用規則",
"content": "トイレの使用は12:00〜13:00に限る。それ以外の時間帯は申請制となり、承認には部長の印鑑が必要。"
}
こういったブラック企業社内ルールを RAG を使って回答するツールです.
今回,比較のため RAG あり,RAG なしの両方の回答を掲載します.
サンプル1
- 問い合わせ内容
社内規則に関して,朝の挨拶のルールを教えてください
- RAG なし
[INFO] [RAG なし] answer 社内ルールに基づいて、朝の挨拶に関しては、以下のポリシーを掲示しています。 - 朝の挨拶は、同僚との協調性を持って行ってください。 - 尊敬を表する挨拶を優先し、プラットフォームやSNSでは適用されません。 - 個々の文化や儀式に従い、挨拶に関しては尊重することを心がけてください。 具体的な挨拶例は社内ルールの公式な文書に記載されている場合がありますので、 そのリソースに確認してください。
- RAG あり
[INFO] [RAG あり] answer 社内規則において、朝の挨拶に関するルールは次のとおりです。 出社時には、3回のお辞儀をしてから席につくことです
サンプル2
- 問い合わせ内容
今日、定時で帰っていいですか?
- RAG なし
[INFO] [RAG なし] answer 社内ルールに基づいて答えるためには、そのルールの内容に関する情報が必要です。 しかし、提供された情報からは、「今日、定時で帰っていいですか?」に対する具体的な答えを提供することはできません。 社内ルールを確認し、その中で「定時で帰っていいですか?」に関するポリシーがあれば、それに基づいた回答を提供できます
- RAG あり
[INFO] [RAG あり] answer はい、今日定時に帰ることはできません。 社内ルールによると、残業は毎日義務であり、定時に退社することができません。事前の根拠がなければなりません。 また、昼休憩は12:30〜12:35の間だけ可能であるため、今日の昼休憩と同じ時間帯に定時に退社することはできないでしょう。
どちらのサンプルの問い合わせでも(多少おかしな日本語はありますが),RAG ありではしっかりとブラックな回答を得ることができました!
一方で,RAG なしの方では,当たり障りの無い内容や根拠が見つからないといった内容の返答でした.
この結果を比較するに,うまく RAG で手元の情報を活用できていることがわかります.
実際に手元でこのツールを動かしたい方は,GitHub の README を参考にしてみてください.
おわりに
今回は,Retrieval-Augmented Generation(RAG)の技術検証を目的として,ブラック企業の社内規則というちょっと(だいぶ?)ふざけた題材を使って,RAG 構成をイチから実装してみました.
RAG なしでは社内規則を反映した回答が出せなかったところに,外部情報(社内規則)を明示的に渡すことで,LLM の回答精度が一気に向上し,期待する回答を得られることが確認できました.
特に,完全ローカル環境かつ軽量な構成を意識して Phi × FAISS × llama-cpp-python での実装をし,ここまで実用的な動きができたのは驚きです.
試してみてわかった“つまづきポイント”と注意点
-
LLM の出力終端トークン(stop) を甘くすると,
→ モデルが止まらず延々と話し続ける暴走モードに突入します
→<|end|>
や<|assistant|>
のような明確な区切りトークンが超重要! -
RAG の文脈(context)をプロンプトに埋め込むときは,
→ 「どんな形式で渡すか」 が精度に影響します
→ 適当な文章をベタ貼りするだけではモデルが理解してくれないこともあるため、プロンプト設計はかなり重要!
→ 今回のプロンプトはmake_prompt()
を参照 -
検索結果や生成回答がイマイチな場合は,
→ 埋め込みモデルや LLM のモデルの変更,TOP_K の数(検索件数)を見直すだけでも改善できるケースが多いです
→ 精度チューニングの余地は大きいので,色々試してみる価値あり!
→ 特に,日本語特化のモデルにしてみたりとかは有効なことが多いです(今回私もいくつか試したりしてみました)
今後はもう少し真面目な文書(論文・マニュアルなど)に差し替えて,WebUI 化,API 化,他モデル対応などにも発展させていきたいと考えています.
📎 実装コードはこちら → GitHub:rag-sample-practice
ここまで読んでくださりありがとうございました!
「ブラックすぎて面白かった」「技術的に参考になった」という方は,ぜひ「いいね」いただけると励みになります🔥