第02回 学習メモ: RAGチャットボットの基礎(ChromaDB + Chainlit)¶
全体像¶
第01回の「会話履歴つきチャットボット」に、ベクトル検索による外部知識(2023年の出来事)の注入を加えた最小RAG。LangChainは使わず ChromaDB を直叩きしている。
初期化フェーズ(build_index.py、初回のみ)¶
flowchart LR
JSON["events_2023.json<br/>月→日→記事配列の3階層"]
Flat["flatten_events<br/>list of strings に展開"]
Embed["OpenAI Embedding API<br/>(text-embedding-3-small)"]
Chroma["ChromaDB<br/>(PersistentClient: ./data)"]
JSON --> Flat --> Embed --> Chroma
実行フェーズ(chatbot.py、ユーザー発話ごと)¶
flowchart TD
Start([ユーザー発話 受信]) --> AppendHist[history に user メッセージを append]
AppendHist --> Query["直近の user 発話で<br/>collection.query(n_results=5)"]
Query --> Filter{distance ≤ 0.6 の<br/>ヒットがあるか?}
Filter -->|あり| BuildContext[ヒットした記事を結合し<br/>『関連情報』として整形]
BuildContext --> Inject["history のコピーに<br/>system role で関連情報を append<br/>(元の history は汚さない)"]
Inject --> Call
Filter -->|なし| Call["OpenAI Chat Completions API<br/>(messages=history のコピー)"]
Call --> Reply[応答を生成]
Reply --> ShowStep{関連情報を<br/>取得できたか?}
ShowStep -->|あり| ShowRel[cl.Step で<br/>関連情報を折りたたみ表示]
ShowStep -->|なし| Skip[skip]
ShowRel --> ShowMsg
Skip --> ShowMsg
ShowMsg[cl.Message で<br/>妖精キャラの応答を表示]
ShowMsg --> SaveHist[history に assistant を append]
SaveHist --> End([次のターン待ち])
要点:
- history を直接汚さない設計: 関連情報は list(history) の防御コピーに対してだけ append し、永続化される history には積まない。これで過去の関連情報がセッションに残り続けない
- distance しきい値: 厳しめ(0.6)にすることで「無関係な記事を関連情報として注入してしまう」事故を防ぐ
- role="system" に注入: Chat API は role の繰り返しを許容するため、途中で system message を追加して「今だけ参考にしてほしい知識」を伝える
「枝豆の妖精」キャラ + 関連情報を素材に小学生向けにアレンジして話す、という指示の組み合わせ。
使用ライブラリ・原理¶
ChromaDB¶
- 軽量なローカルベクトルデータベース
- 構成要素:
- Client: ストレージへの接続。
chromadb.PersistentClient(path="./data")でディスク永続化 - Collection: ベクトルの入れ物(テーブルに相当)。複数作って用途別に使い分けられる
- Embedding Function: テキスト→ベクトル変換の差し込み口。ここでは
OpenAIEmbeddingFunction(model_name="text-embedding-ada-002") - 主要操作:
client.create_collection(name, embedding_function=...)/client.get_collection(...)collection.add(documents=[...], ids=[...])で投入(embedding は EF が自動計算)collection.query(query_texts=[...], n_results=K)で類似検索 →documentsdistancesmetadatasが返る- 距離はコサイン距離(小さいほど類似)。0 が完全一致、1 が無関係、2 が真逆。
0.4以下を関連と見なすのは経験則
RAGの最小骨格¶
「LLMの応答 = system prompt + 会話履歴 + 外部知識」と組み立てる構造。
- 外部知識はベクトル検索で「ユーザーの直近発話に意味的に近い」ものだけを動的に注入する
- 注入の場所は role="system" の追加メッセージ(このコードでは history の末尾に push してから API へ送っている)
- 注入失敗(ヒットなし/距離が遠い)の場合は何も入れないことで、LLMが「関連情報なし」と判断できる構造
Chainlit の再登場¶
- 第01回と同じ
@cl.on_chat_start@cl.on_messagecl.user_session.get/setを使用 - 追加で
cl.Message(author="relevant", content=..., indent=1)で検索ヒットを「relevant」という発信者名・字下げ付きで表示 → デバッグ用にRAGの参照ソースをUIに可視化する手法
ファイル別の役割¶
| ファイル | 役割 |
|---|---|
chatbot.py |
Chainlit + OpenAI Chat Completions + ChromaDB を結合した RAG チャットボット本体(現行API版) |
build_index.py |
events_2023.json を ChromaDB の events_2023 コレクションに投入する初期化スクリプト(本リポジトリで自作、連載原典には含まれていない) |
events_2023.json |
「2023年の出来事」が月→日付→記事配列の3階層ネスト構造で格納されたコーパス(約220行) |
data/ |
build_index.py の出力。ChromaDB の SQLite + parquet 永続化先 |
学んだこと(要点)¶
- distanceしきい値で「無関係な情報を入れない」設計: コサイン距離 0.4 以下のみを採用。RAGは入れすぎるとノイズになるので、フィルタ条件が品質の要
- system role の途中追加: history の途中に system message を増設して「今だけ参考にしてほしい知識」を伝える。Chat APIは role の繰り返しを許容する
- 検索クエリ = 直近のユーザー発話: 第27回で出てきた
RewriteQueryのような工夫はまだない。素のクエリでベクトル検索→ヒットが甘い場面が出るのが「次の回への伏線」 - JSON自体ではなく、JSONから生成した『出来事文字列の配列』を投入する想定: 連載記事内で別途 ChromaDB への loader を書いていると推定(このフォルダ単独では再現不能)
- Chainlit の
author機能で検索ヒットを別バブルで可視化: ユーザー視点で「LLMが何を根拠に答えたか」が分かる学習用UI
拡張アイデア¶
events_2023.jsonをロードして./dataを構築する初期化スクリプトを書く(このリポジトリには無いので自作)- クエリ書き換え(HyDE / RewriteQuery)を入れて、Recall を改善する → 第27回の DSPy 版と比較する
n_resultsと distance しきい値を変えて、recall/precision のトレードオフを観察する- 検索結果にMMR(Maximal Marginal Relevance) を入れて多様性を確保する
- メタデータ(イベントの月)を
collection.add(metadatas=[...])で付け、where={"month": "1月"}での条件付き検索を試す
連載原典からの移植で行った変更¶
旧 openai==0.x / 旧 chromadb 前提だったコードを、現行API + 1Password CLI で動く形に書き換えた。
| 箇所 | 原典 | 本フォルダ |
|---|---|---|
| ChatGPT 呼び出し | openai.ChatCompletion.create(...) |
OpenAI().chat.completions.create(...)(v1 系の新形式) |
| Embedding モデル | text-embedding-ada-002 |
text-embedding-3-small(コスト1/5・性能同等以上) |
OpenAIEmbeddingFunction の引数 |
model_name=... のみ |
api_key=..., model_name=... 必須に |
| distance しきい値 | <= 0.4 |
<= 0.6(3-small は距離スケールが ada-002 と異なる経験則) |
| 初期化スクリプト | 無し(前提として求められるが提供されない) | build_index.py を新設。JSON をフラット化して投入 |
| 型ヒント | -> (str, str)(Python の型ヒントとして不正) |
-> tuple[str, str] に修正 |
| APIキー取得 | 環境変数 / 直書き想定 | 1Password CLI 経由(01 と統一) |
@cl.on_message の引数型 |
message: str |
message: cl.Message(message.content で本文取得) |
| 関連情報の表示 | cl.Message(author="relevant", content=..., indent=1) |
cl.Step(name=..., type="retrieval")(indent 引数は現行で削除) |
| 直近ユーザー発話の参照 | history[-1](直前に push 済み前提) |
next((m for m in reversed... if role == "user")) で型に依らず検索 |
| history の防御コピー | なし(system message が history に永続化される副作用) | list(...) でコピーしてから注入(履歴を汚さない) |
history を汚さない工夫(地味だけど重要)¶
原典コードには 「関連情報を history に直接 append したまま OpenAI に送る」 という挙動があり、次のターンの履歴に system message として残り続けてしまう副作用があった。本フォルダでは messages = list(cl.user_session.get("history")) で毎ターン防御コピーを取り、そのコピーにだけ関連情報を注入する形に変えている。これでセッション内に古い関連情報が累積しない。
起動方法¶
詳細は README.md を参照。サマリだけ:
cd 02
# Step 1: 初回のみ。events_2023.json → ChromaDB を構築
uv run --no-project --with "openai>=1.0" --with "chromadb>=0.5" \
python build_index.py
# Step 2: チャットボット起動
uv run --no-project --with "openai>=1.0" --with "chromadb>=0.5" --with "chainlit" \
chainlit run chatbot.py -w
記事参照¶
- Software Design 2023年〜の連載 第02回
作成: 2026-05-17 / 最終更新: 2026-06-10