Retrieval-Augmented Generation（RAG） を使って "ブラック企業の社内規則チャットボット" を作ってみた

# RAG で "ブラック企業社内規則チャットボット" を作ってみた

## はじめに

本記事では Retrieval-Augmented Generation（RAG）の勉強・技術検証を目的に，**「ブラック企業の社内規則」を題材としたチャットボット**を作成しました．

その内容は完全なフィクションであり，実在の企業・団体・人物とは一切関係ありません．
もしこのような表現が苦手な方は，閲覧にご注意ください．

とはいえ，前半部分は RAG の説明，後半部分にデモがあるので，デモを避けていただければと思います．

本記事で使用したコードは，以下のリポジトリにて公開しています． 
🔗 [GitHub：rag-sample-practice](https://github.com/k2-gc/rag-sample-practice)

今回使用した構成やデモコードは，**完全ローカル環境**かつ**軽量で
 CPU で動作可能**な構成を意識して実装しています．

## RAG とは
まず，RAG の簡単な説明です．

### 目的
RAG は Large Language Model（LLM）を再学習せずに，**新しいデータの情報や手持ちのデータを回答へ反映させる**ための仕組みです．

世の中の LLM は大規模なデータと計算リソースを用いて学習されています．そのため，一個人や企業でイチから学習したり再学習を行うのは現実的ではありません．

しかし，**LLM が学習していな情報を元に質問したい場面**は多くあります．
そのようなときに RAG を活用することで，LLM に外部の情報を与えながら回答を得ることが可能になります．

### 活用例
- ✅ 社内ナレッジ検索  
  → 社員マニュアルや FAQ をもとに社内 Bot を作る  
- ✅ 最新情報を用いたチャット  
  → 法改正・論文・時事ニュースなどで最新の回答を得たい

これらは一般的な LLM には含まれていない情報のため，通常の対話では**誤った回答（ハルシネーション）** を引き起こす可能性があります．  
RAG を使うことで，それを防ぎつつ正確な知識に基づいた回答が可能になります．

### 仕組み
RAG の基本的な仕組みは下記です．

#### データの準備
1. 回答の元となる**外部情報（ドキュメントなど）** を用意する  
2. それらの情報を **ベクトル（数値表現）に変換**し，ベクトルデータベースに登録しておく

（イメージ図）
![rag1  2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/eec92fd9-f476-4563-9700-ea5d5b0ac8e0.jpeg)


#### 質問と回答の流れ
1. ユーザからの質問内容も**ベクトルに変換する**
1. 変換したベクトルを用いて，ベクトルデータベースから質問内容と**類似した文書を検索**
1. 検索したデータ・情報を **LLM へ渡すプロンプトに組み込む**
1. LLM が，**追加された情報を活用して**回答を生成する

（イメージ図）
![rag2 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/398694e4-36f7-4081-a1fd-71b9f4782c15.jpeg)

今回はこの仕組みを実装してみました．

## 今回の構成
本構成では，GPU 不要かつローカルで軽快に動作する技術を中心に選定しました．
使用した主要なコンポーネントは以下の通りです．

### 使用技術
|  | 名前 | 説明 |
|:-:|:-:| :-: |
| LLM モデル | Phi-3-mini-4k-instruct-q4.gguf | Microsoft が提供する LLM <br> ビット精度 4bit で軽量|
| 埋め込みモデル |  sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 | 文書を特定次元の特徴ベクトルに落とし込むモデル <br> 日本語対応でそれなりに高精度 |
| ベクトル検索ライブラリ | FAISS| 類似ベクトルを高速に検索するためのライブラリ <br> Meta が開発 |
| LLM 推論ライブラリ | llama-cpp-python（llama_cpp）| gguf モデルが動作可能な LLM 推論ライブラリ <br> Python から簡単にローカル LLM を呼び出せる|

先ほどの図で，技術を色分けすると下図になります．
1. データの準備
![rag3 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/2da0ace4-8c3b-4f4b-b0c9-d4b976e91c82.jpeg)

1. 質問と回答
![rag4 2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4078542/1ee5f556-778e-47a9-af0b-4ed3d90a1626.jpeg)

## コード解説
ここからは前述技術を用いたコードの説明をします．

（再掲）本記事で使用したコードは，以下のリポジトリにて公開しています． 
🔗 [GitHub：rag-sample-practice](https://github.com/k2-gc/rag-sample-practice)

### ベクトル DB の作成
使用するコードは[こちら](https://github.com/k2-gc/rag-sample-practice/blob/main/build_index.py)．
#### 外部ドキュメントの読み込み
```python
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": 内容}` を想定
    → [サンプルはこちら](https://github.com/k2-gc/rag-sample-practice/blob/main/data/rules.json)
* 関数の返り値は以下のような文字列のリスト
    ```text
    ["タイトル1:内容1", "タイトル2:内容2", ...]
    ```
* これが「RAG で使用する外部情報の元データ」となります

#### 文字列のベクトルへの変換
```python
logger.info("Creating embedder")
embedder = SentenceTransformer(config["EMBED_MODEL"])
doc_embeddings = embedder.encode(cat_documents)
```
* `SentenceTransformer()` により，埋め込み器を初期化
* 引数には使用するモデルを指定
* `.encode()` を使って，外部文書（文字列）を**数値ベクトル**に変換
    → 本構成では，**384 次元**のベクトルに変換

#### ベクトル DB の作成と保存
```python
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 をファイルとして保存**
    → このファイルは検索時に使用します

これで，外部情報のベクトルの保存が完了しました．

### 問い合わせ
使用するコードは[こちら](https://github.com/k2-gc/rag-sample-practice/blob/main/query.py)．
#### 準備
```python
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` は推論時のスレッド数

#### インデックスに対する検索
```python
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 あり/なしでの問い合わせ
```python

logger.info("[RAG なし] 推論中...")
prompt_wo_RAG = make_prompt("", query)
res_wo_RAG = llm(prompt_wo_RAG, max_tokens=256, stop=["
", "
", "
"])
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=["
", "
", "
"])
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` に「問い合わせ内容」が入る
    → `
`，`
`，`
` は Phi のプロンプトの構文
    ```text
    (
        "
\n"
        "社内ルールに載っていることのみに基づいて答えてください．"
        f"社内ルール：{context}\n\n"
        f"質問：{query}\n"
        "
\n"
        "
\n"
    )
    ```

* RAG なしの場合は，第一引数に "" を指定
* RAG ありの場合は，第一引数に取得したコンテキストを指定
* `llm()` で推論の実行
    → 第一引数でプロンプト，`max_tokens` でトークンの長さ，`stop` には LLM が出力を続けすぎないように終端トークンを指定
* LLM による回答を出力する

## デモ
今回，**ブラック企業の社内ルール**を題材に，社内ルール問い合わせチャットボットを作成してみました．
社内ルールを外部情報として，LLM に回答をしてもらうツールです．
今回使用した社内ルールの全体は[こちら](https://github.com/k2-gc/rag-sample-practice/blob/main/data/rules.json)を参照ください．
例としては下記のようなものがあります．
```json
{
      "title": "トイレ利用規則",
      "content": "トイレの使用は12:00〜13:00に限る。それ以外の時間帯は申請制となり、承認には部長の印鑑が必要。"
}
```

こういったブラック企業社内ルールを RAG を使って回答するツールです．
今回，比較のため RAG あり，RAG なしの両方の回答を掲載します．

### サンプル1
* 問い合わせ内容
    ```text
    社内規則に関して，朝の挨拶のルールを教えてください
    ```
* RAG なし
    ```text
    [INFO] [RAG なし] answer
    社内ルールに基づいて、朝の挨拶に関しては、以下のポリシーを掲示しています。
    
    - 朝の挨拶は、同僚との協調性を持って行ってください。
    - 尊敬を表する挨拶を優先し、プラットフォームやSNSでは適用されません。
    - 個々の文化や儀式に従い、挨拶に関しては尊重することを心がけてください。
    
    具体的な挨拶例は社内ルールの公式な文書に記載されている場合がありますので、
    そのリソースに確認してください。
    ```
* RAG あり
    ```text
    [INFO] [RAG あり] answer
    社内規則において、朝の挨拶に関するルールは次のとおりです。
    出社時には、3回のお辞儀をしてから席につくことです
    ```


### サンプル2
* 問い合わせ内容
    ```text
    今日、定時で帰っていいですか？
    ```
* RAG なし
    ```text
    [INFO] [RAG なし] answer
    社内ルールに基づいて答えるためには、そのルールの内容に関する情報が必要です。
    しかし、提供された情報からは、「今日、定時で帰っていいですか？」に対する具体的な答えを提供することはできません。
    社内ルールを確認し、その中で「定時で帰っていいですか？」に関するポリシーがあれば、それに基づいた回答を提供できます
    ```
* RAG あり
    ```text
    [INFO] [RAG あり] answer
    はい、今日定時に帰ることはできません。
    社内ルールによると、残業は毎日義務であり、定時に退社することができません。事前の根拠がなければなりません。
    また、昼休憩は12:30〜12:35の間だけ可能であるため、今日の昼休憩と同じ時間帯に定時に退社することはできないでしょう。
    ```

どちらのサンプルの問い合わせでも（多少おかしな日本語はありますが），RAG ありではしっかりと**ブラックな回答**を得ることができました！
一方で，RAG なしの方では，当たり障りの無い内容や根拠が見つからないといった内容の返答でした．
この結果を比較するに，うまく RAG で手元の情報を活用できていることがわかります．

実際に手元でこのツールを動かしたい方は，[GitHub の README](https://github.com/k2-gc/rag-sample-practice) を参考にしてみてください．

## おわりに
今回は，Retrieval-Augmented Generation（RAG）の技術検証を目的として，ブラック企業の社内規則というちょっと（だいぶ？）ふざけた題材を使って，RAG 構成をイチから実装してみました．

RAG なしでは社内規則を反映した回答が出せなかったところに，**外部情報（社内規則）を明示的に渡すことで，LLM の回答精度が一気に向上し，期待する回答を得られる**ことが確認できました．
特に，**完全ローカル環境**かつ**軽量な構成**を意識して **Phi × FAISS × llama-cpp-python** での実装をし，ここまで実用的な動きができたのは驚きです．

---
### 試してみてわかった“つまづきポイント”と注意点

- **LLM の出力終端トークン（stop）** を甘くすると，　 
  → モデルが止まらず**延々と話し続ける暴走モード**に突入します  
  → `
` や `
` のような明確な区切りトークンが超重要！

- **RAG の文脈（context）をプロンプトに埋め込むとき**は，  
  → **「どんな形式で渡すか」** が精度に影響します  
  → 適当な文章をベタ貼りするだけではモデルが理解してくれないこともあるため、**プロンプト設計はかなり重要！**
  → 今回のプロンプトは `make_prompt()` を参照

- **検索結果や生成回答がイマイチな場合**は，  
  → **埋め込みモデルや LLM のモデルの変更**，**TOP_K の数（検索件数）を見直す**だけでも改善できるケースが多いです  
  → **精度チューニングの余地は大きい**ので，色々試してみる価値あり！
  → 特に，日本語特化のモデルにしてみたりとかは有効なことが多いです（今回私もいくつか試したりしてみました）

---

今後はもう少し真面目な文書（論文・マニュアルなど）に差し替えて，**WebUI 化，API 化，他モデル対応**などにも発展させていきたいと考えています．

---

📎 実装コードはこちら → [GitHub：rag-sample-practice](https://github.com/k2-gc/rag-sample-practice)

---

ここまで読んでくださりありがとうございました！  
「ブラックすぎて面白かった」「技術的に参考になった」という方は，ぜひ「いいね」いただけると励みになります🔥