RAG(検索拡張生成)を個人開発に取り入れてみた話

「自分のドキュメントに対してAIが質問に答えてくれる」という仕組みを個人開発に導入した。技術的には RAG(Retrieval-Augmented Generation)と呼ばれるもので、思ったより簡単に実装できた。試行錯誤した記録をまとめておく。

RAGとは何か

LLMは学習データに含まれる知識しか持っていない。自分の社内ドキュメントや個人のメモには当然アクセスできない。

RAGはこの問題を解決する手法だ。

ユーザーの質問
      ↓
ドキュメント群から関連箇所を検索(Retrieval)
      ↓
質問 + 関連ドキュメントをLLMに渡す
      ↓
LLMが文脈を踏まえて回答(Generation)

「ドキュメントを検索してからLLMに渡す」という2ステップが本質で、これによりLLMが知らないはずの情報に基づいた回答ができる。

実装したもの

自分の技術メモ(Obsidianのvaultにある約300ファイルのMarkdown)に対して質問できるシステムを作った。

「先月のあのAPIの使い方メモ、どこだっけ」という状況を解決したかった。

技術スタック

  • **ベクトルDB**: ChromaDB(ローカルで動く、無料)
  • **Embeddings**: OpenAI text-embedding-3-small
  • **LLM**: Claude 3.7 Sonnet(回答生成)
  • **Pythonライブラリ**: chromadb, anthropic, openai
  • pip install chromadb anthropic openai

    Step 1: ドキュメントをベクトル化して保存する

    import chromadb
    from openai import OpenAI
    from pathlib import Path
    
    def embed_documents(docs_dir: str, collection_name: str = "my-notes"):
        """Markdownファイルをベクトル化してChromaDBに保存する"""
        client = chromadb.PersistentClient(path="./chroma_db")
        collection = client.get_or_create_collection(collection_name)
    
        openai_client = OpenAI()
    
        docs_path = Path(docs_dir)
        md_files = list(docs_path.rglob("*.md"))
    
        print(f"{len(md_files)}件のファイルを処理中...")
    
        for i, filepath in enumerate(md_files):
            content = filepath.read_text(encoding="utf-8")
            if len(content.strip()) < 50:  # 短すぎるファイルはスキップ
                continue
    
            # 長いドキュメントはチャンクに分割
            chunks = split_into_chunks(content, chunk_size=500)
    
            for j, chunk in enumerate(chunks):
                # ベクトル化
                response = openai_client.embeddings.create(
                    model="text-embedding-3-small",
                    input=chunk
                )
                embedding = response.data[0].embedding
    
                # ChromaDBに保存
                collection.add(
                    ids=[f"{filepath.stem}_{j}"],
                    embeddings=[embedding],
                    documents=[chunk],
                    metadatas=[{"source": str(filepath), "chunk": j}]
                )
    
            if (i + 1) % 10 == 0:
                print(f"  {i + 1}/{len(md_files)} 完了")
    
        print("インデックス作成完了")
    
    
    def split_into_chunks(text: str, chunk_size: int = 500) -> list[str]:
        """テキストを指定サイズのチャンクに分割する"""
        words = text.split()
        chunks = []
        current_chunk = []
        current_size = 0
    
        for word in words:
            current_chunk.append(word)
            current_size += len(word) + 1
    
            if current_size >= chunk_size:
                chunks.append(" ".join(current_chunk))
                current_chunk = []
                current_size = 0
    
        if current_chunk:
            chunks.append(" ".join(current_chunk))
    
        return chunks

    Step 2: 質問に対して関連ドキュメントを検索する

    def search_similar_docs(query: str, n_results: int = 5) -> list[dict]:
        """質問に関連するドキュメントを検索する"""
        client = chromadb.PersistentClient(path="./chroma_db")
        collection = client.get_collection("my-notes")
    
        openai_client = OpenAI()
    
        # 質問をベクトル化
        response = openai_client.embeddings.create(
            model="text-embedding-3-small",
            input=query
        )
        query_embedding = response.data[0].embedding
    
        # 類似ドキュメントを検索
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=n_results,
            include=["documents", "metadatas", "distances"]
        )
    
        docs = []
        for doc, meta, dist in zip(
            results["documents"][0],
            results["metadatas"][0],
            results["distances"][0]
        ):
            docs.append({
                "content": doc,
                "source": meta["source"],
                "similarity": 1 - dist  # コサイン距離を類似度に変換
            })
    
        return docs

    Step 3: LLMで回答を生成する

    import anthropic
    
    def answer_question(question: str) -> str:
        """質問に対してRAGで回答する"""
        # 関連ドキュメントを検索
        relevant_docs = search_similar_docs(question, n_results=5)
    
        # コンテキストを構築
        context_parts = []
        for doc in relevant_docs:
            if doc["similarity"] > 0.5:  # 類似度が低すぎるものは除外
                source = doc["source"].split("/")[-1]
                context_parts.append(f"[{source}]\n{doc['content']}")
    
        if not context_parts:
            return "関連するドキュメントが見つかりませんでした。"
    
        context = "\n\n---\n\n".join(context_parts)
    
        # Claude APIで回答生成
        client = anthropic.Anthropic()
        message = client.messages.create(
            model="claude-3-7-sonnet-20250219",
            max_tokens=1024,
            system="""あなたは技術メモの内容に基づいて質問に答えるアシスタントです。
    提供されたドキュメントの内容のみを根拠に答えてください。
    ドキュメントに記載のない情報は「ドキュメントには記載がありません」と答えてください。""",
            messages=[{
                "role": "user",
                "content": f"ドキュメント:\n{context}\n\n質問: {question}"
            }]
        )
    
        return message.content[0].text
    
    
    # 使用例
    answer = answer_question("ChromaDBの接続設定はどうするんだっけ?")
    print(answer)

    実際に使ってみた結果

    300ファイル・約10万文字のObsidian vaultをインデックス化した。インデックス作成は最初の1回だけで5分くらいかかったが、その後の検索は1〜2秒で返ってくる。

    実際に便利だったのは:

  • 「あのAPIの認証方法のメモ」を探すとき:ファイル名を思い出せなくても質問するだけで見つかる
  • 「自分のメモにはどのデータベースの使い方が書いてある?」という俯瞰的な質問
  • 古いメモとの矛盾を確認する(「以前はこう書いてたけど今でも正しい?」)
  • ハマったポイント

    チャンクサイズの調整: 最初500文字で分割していたが、コードブロックの途中で切れてしまい意味が通じないチャンクが生まれた。コードブロックを検出して分割しないように改善した。

    類似度の閾値: 0.5より低い類似度のドキュメントは精度が悪く、ノイズになった。用途に合わせて0.5〜0.7で調整するのがポイント。

    embeddings のコスト: text-embedding-3-smallは安い(1,000トークンで$0.00002)ので、300ファイルのインデックス化でも数十円だった。

    まとめ

    RAGを個人開発レベルで試すのは思ったより難しくなかった。ChromaDBがローカルで動くので、クラウドサービスへの依存もない。

    今後試してみたいこと:

  • ハイブリッド検索(ベクトル検索 + キーワード検索の組み合わせ)
  • 定期的な自動インデックス更新
  • GitHubのコードベースへの適用
  • 自分のドキュメントに対してAIが答えてくれるシステムは、ナレッジベースが増えるほど価値が上がる。早めに導入しておくのがおすすめ。