tech-blog-rag の仕組み:自分の技術記事に質問できる RAG システムを作った

8 min
raggeminipythonchromadbllm

このシステムは何をするのか?

tech-blog-rag は、Zenn に投稿された自分の技術記事に対して「質問」ができる CLI ツールです。

uv run python scripts/query.py -q "ReactのuseStateとは何ですか?"

このように質問すると、記事の内容を踏まえた回答が返ってきます。

なぜ普通に AI に聞かないのか?

Gemini などの LLM は、学習データの範囲でしか答えられません。自分が Zenn に書いた記事は LLM の学習データに含まれていないため、「自分の記事について」聞いても答えられないのです。

// 普通の AI への質問
あなた: 「foo さんの Zenn の React 記事に何が書いてあった?」
Gemini: 「その記事については情報を持っていません」

// このシステムを使った質問
あなた: 「ReactのuseStateとは?」
システム: (記事を検索して関連部分を見つける)
Gemini: 「あなたの記事によると、useStateは...」

RAG とは何か?

RAG は Retrieval-Augmented Generation(検索拡張生成)の略で、質問に関係する資料をその場で検索して、AI に渡して答えさせる手法です。詳しくは NRI の RAG 解説ページが参考になります。

RAG の処理フロー

今回実装したシステムでは、処理を大きく 2 段階に分けています。RAG の構成はもっと複雑にもできますが、ここではシンプルな構成を採用しました。

【事前準備】ingest(取り込み)

記事の Markdown を読む
↓
検索しやすいサイズに分割(チャンク化)
↓
各チャンクをベクトルに変換(Embedding)
↓
ベクトル DB(ChromaDB)に保存

【使用時】query(質問応答)

質問をベクトルに変換(Embedding)
↓
ベクトル DB で類似チャンクを検索
↓
上位 N 件のチャンクを取得
↓
チャンクをコンテキストとして AI に渡す
↓
AI が回答を生成

技術スタック

技術用途
Python 3.12+実装言語
uvパッケージ管理
Gemini API (gemini-2.5-flash)回答生成
Gemini API (gemini-embedding-001)Embedding(ベクトル化)
ChromaDBローカルベクトル DB
python-frontmatterZenn 記事の frontmatter パース

重要な概念の説明

実装の説明に入る前に、重要な概念を整理しておきます。

トークンとは?

AI がテキストを処理するとき、文章をトークンと呼ばれる単位に分割します。 トークン化は Embedding API の内部で自動的に行われるため、コード上では意識する必要はありません。

チャンクとは?

記事全文をそのまま AI に渡すと、2 つの問題があります。

  1. 上限超過: Embedding API にはトークン数の上限がある
  2. 精度低下: 関係ない情報まで含まれると回答がぼやける

そこで記事を「チャンク」と呼ばれる小さな塊に分割します。このシステムでは 500 文字を目安に分割しています。

【記事全文(3000文字)】
↓ チャンク分割(500文字ずつ)
chunk_0: 冒頭の500文字
chunk_1: 次の500文字(100文字は前と重複)
chunk_2: さらに次の500文字(100文字は前と重複)
...

100 文字重複させる(オーバーラップ)のは、チャンクの境界で文脈が途切れるのを防ぐためです。

トークンとチャンクの違い

チャンクトークン
目的検索しやすいサイズに分割するAI が処理できる単位に分割する
単位数百文字数文字〜単語
誰が作るかこのシステム(chunker.py)Embedding API の内部

チャンク分割 → トークン化 → ベクトル化 の順番で処理されます。

Embedding(ベクトル化)とは?

テキストの「意味」を数値のリスト(ベクトル)として表現することです。このシステムで使っている gemini-embedding-001 は、デフォルトで 3072 個の数値のリストに変換します(output_dimensionality で 768 や 1536 などに変更可能)。

"ReactのHooks"    → [0.12, -0.85, 0.34, 0.71, ...]  // 3072個の数値のリスト
"useStateの使い方" → [0.11, -0.82, 0.31, 0.69, ...]  // 意味が近いので値も近い
"Pythonの文法"    → [0.91,  0.23, -0.67, 0.12, ...]  // 意味が遠いので値も遠い

意味が似ているテキストは、似たようなベクトルになります。これを利用して「質問と意味的に近いチャンク」を探します。

コサイン類似度とは?

ベクトル同士の「近さ」を測る計算方法です。自分の理解では、ベクトルを矢印として考え、矢印の向きがどれだけ一致しているかを -1〜1 の数値で表すものです。テキストの長さが違っても同じ意味なら同じ方向を向くため、「長さ」ではなく「方向」で類似度を測ります。詳しくは Wikipedia のコサイン類似度の記事を参照してください。

ChromaDB とは?

ベクトルを保存・検索するための専用データベースです。

-- 普通の DB → 条件一致で検索
SELECT * WHERE title = 'React'

-- ChromaDB → 意味の近さで検索
-- 「ReactのHooksに似たベクトルを持つチャンクを上位5件返して」

ローカルのファイルとして保存されるため、サーバー不要・無料で使えます。内部は SQLite で動いており、ベクトルの近さを高速に探すために HNSW というアルゴリズムを使っています。

各モジュールの詳細

config.py(設定ファイル)

システム全体で使う定数を一箇所に集めたファイルです。

EMBEDDING_MODEL = "gemini-embedding-001"   # テキスト→ベクトル変換モデル
GENERATION_MODEL = "gemini-2.5-flash"      # 回答生成モデル
CHROMA_DB_PATH = "data/chroma_db"          # ベクトル DB の保存場所
CHUNK_SIZE = 500                            # チャンクの最大文字数
CHUNK_OVERLAP = 100                        # チャンク間の重複文字数
TOP_K = 5                                  # 検索で返す上位件数

TOP_K = 5 は「質問に対して最も関連性の高いチャンクを上位 5 件取得する」という意味です。この 5 件が Gemini に渡すコンテキストになります。

collector.py(記事の読み込み)

Zenn の記事 Markdown ファイルを読み込んで Article オブジェクトのリストを返します。

@dataclass
class Article:
    slug: str        # "react-hooks-guide"
    title: str       # "ReactのHooksを理解する"
    url: str         # "https://zenn.dev/ryo_manba/articles/react-hooks-guide"
    topics: list[str]  # ["react", "typescript"]
    body: str        # 記事本文
    published: bool  # True

重要な処理として、published: true の記事だけを対象にし(下書きは除外)、1 ファイルの読み込みに失敗しても logging.warning で記録して続行します。

chunker.py(チャンク分割)

Article の本文を検索しやすい小さな塊(Chunk)に分割します。

コードブロックの保護が重要なポイントです。普通に分割するとコードブロックの途中で切れてしまうため、分割前にコードブロックをプレースホルダに置換して保護します。

# 1. コードブロックをプレースホルダに置換
#    ```python
#    def hello():
#        pass
#    ```
#    ↓
#    __CODE_BLOCK_0__

# 2. プレースホルダの状態でチャンク分割(コードは分割されない)

# 3. 分割後にプレースホルダをコードブロックに戻す

分割ロジックは、段落(\n\n 区切り)単位でテキストを貯めていき、CHUNK_SIZE を超えたら切り出す仕組みです。

embedder.py(ベクトル化と DB 保存)

チャンクのテキストをベクトルに変換し、ChromaDB に保存します。

Gemini の Embedding API には task_type という設定があり、保存するチャンク側には RETRIEVAL_DOCUMENT、検索する質問側には RETRIEVAL_QUERY を使い分けることで検索精度が上がります。

また、チャンクが大量にある場合は 100 件ずつバッチ処理し、バッチ間に 0.5 秒待機することで Rate Limit 対策をしています。

retriever.py(類似検索)

ユーザーの質問をベクトル化して、ChromaDB から意味的に近いチャンクを取得します。

# 1. 質問をベクトル化(RETRIEVAL_QUERY を使う)
query_vec = embed_text(client, "hooksとは?", task_type="RETRIEVAL_QUERY")

# 2. ChromaDB でコサイン類似度検索(hnsw:space を "cosine" に設定)
results = collection.query(
    query_embeddings=[query_vec],
    n_results=5,  # TOP_K=5
)

generator.py(回答生成)

検索で取得したチャンクをコンテキストとして Gemini に渡し、回答を生成します。

system_instruction(AI の役割・ルールを定義)とユーザープロンプト(コンテキスト+質問)の 2 層構造になっています。

【system_instruction】
あなたは技術ブログの Q&A アシスタントです。
- 提供されたコンテキストに基づいてのみ回答してください
- コンテキストに情報がない場合は「記事が見つかりません」と答えてください
- 回答の最後に出典を記載してください

【user prompt】
コンテキスト + 質問

「コンテキストに基づいてのみ回答」というルールが重要です。これがないと Gemini が学習データで補完してしまい、記事に書いていないことを答えるリスクがあります。

temperature=0.3 と低めに設定し、ハルシネーションを抑えています。

pipeline.py(パイプライン)

全モジュールをつなぐ「司令塔」です。

# ingest(取り込み)
def ingest(content_dir, db_path):
    articles = load_articles(articles_dir)    # collector: 記事読み込み
    chunks = chunk_articles(articles)          # chunker: チャンク分割
    client = get_client()                      # Gemini クライアント
    embeddings = embed_chunks(client, chunks)  # embedder: ベクトル化
    stored = store_chunks(chunks, embeddings)  # embedder: DB 保存

# query(質問応答)
def query(question, top_k, db_path):
    client = get_client()
    results = search(client, question, top_k)   # retriever: 類似検索
    answer = generate(client, question, results) # generator: 回答生成
    return answer

使い方

# 依存インストール
uv sync

# 記事を取り込む
uv run python scripts/ingest.py --content-dir ../zenn-content

# 質問する
uv run python scripts/query.py -q "hooksとは?"

# 対話モード
uv run python scripts/query.py

全体まとめ

【処理の全体フロー】

ingest:
  Markdown → collector → Article
                       → chunker → Chunk
                                 → embedder → ベクトル → ChromaDB

query:
  質問 → embedder → ベクトル → retriever → 類似 Chunk
                                          → generator → 回答

各モジュールが単一の責務を持ち、pipeline.py がそれらをつなぐシンプルな設計です。RAG の本質は「質問に関係する情報を検索して AI に渡す」というだけのことですが、チャンク分割やプロンプト設計など、精度を左右する細かな工夫があります。