コンテンツにスキップ

第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) で類似検索 → documents distances metadatas が返る
  • 距離はコサイン距離(小さいほど類似)。0 が完全一致、1 が無関係、2 が真逆。0.4 以下を関連と見なすのは経験則

RAGの最小骨格

「LLMの応答 = system prompt + 会話履歴 + 外部知識」と組み立てる構造。 - 外部知識はベクトル検索で「ユーザーの直近発話に意味的に近い」ものだけを動的に注入する - 注入の場所は role="system" の追加メッセージ(このコードでは history の末尾に push してから API へ送っている) - 注入失敗(ヒットなし/距離が遠い)の場合は何も入れないことで、LLMが「関連情報なし」と判断できる構造

Chainlit の再登場

  • 第01回と同じ @cl.on_chat_start @cl.on_message cl.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.Messagemessage.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