🔎

RTX 4080でRAGを自作する — Ollama × ChromaDB × Python 150行の全記録

に公開

はじめに

「社内ドキュメントをAIに検索させたい」「自分のブログ記事をLLMに読ませて質問応答したい」

2026年、RAG(Retrieval-Augmented Generation)はAIアプリの定番アーキテクチャになった。しかし多くの解説記事は OpenAI API + Pinecone を前提としていて、完全ローカルで動く実装の情報は少ない。

この記事では、RTX 4080 (16GB VRAM) 1枚で、外部APIゼロ・月額ゼロで動くRAGシステムを自作した全記録を公開する。

実験の構成

結果のサマリー

指標
Embedding速度 38 texts/s
平均検索時間 49ms
E2E応答時間 9.4秒
インジェスト 19ファイル → 487チャンク / 13.5秒
外部API なし(完全ローカル)
月額コスト 0円

使用技術

コンポーネント 選定 理由
Embeddingモデル nomic-embed-text (274MB) 軽量、Ollama対応、768次元
ベクトルDB ChromaDB Python一行で起動、永続化対応
チャットモデル qwen3:14b (9.3GB) 日本語品質◎、72 tok/s
フレームワーク なし(素のPython) LangChain不要で透明性を確保

なぜLangChainを使わないのか

LangChainは便利だが、RAGの仕組みを理解するには抽象化が邪魔になる。本記事では全処理をPython 150行で書く。何が起きているか全部見える。

ステップ1: 環境構築

# Ollamaでモデルをpull
ollama pull nomic-embed-text   # Embedding用 (274MB)
ollama pull qwen3:14b          # チャット用 (9.3GB)

# Python環境
python3 -m venv venv
source venv/bin/activate
pip install chromadb ollama

所要時間: 約2分。LangChain、llama-index、sentence-transformers は不要。

ステップ2: ドキュメントのチャンク分割

RAGの精度を左右する最重要パラメータがチャンクサイズだ。

CHUNK_SIZE = 500       # 文字数
CHUNK_OVERLAP = 100    # オーバーラップ

def chunk_text(text, chunk_size=500, overlap=100):
    """段落境界を尊重してチャンク分割"""
    paragraphs = re.split(r'\n\s*\n', text)
    chunks = []
    current = ""

    for para in paragraphs:
        para = para.strip()
        if not para:
            continue
        if len(current) + len(para) > chunk_size and current:
            chunks.append(current.strip())
            current = current[-overlap:] + "\n\n" + para
        else:
            current = current + "\n\n" + para if current else para

    if current.strip():
        chunks.append(current.strip())
    return chunks

なぜ500文字なのか

チャンクサイズ 特性
200文字 精度高いが文脈が切れる。ノイズが増える
500文字 バランス型。段落1〜2個分
1000文字 文脈は豊富だがノイズ混入。検索精度が下がる

経験則として、日本語の技術文書は400〜600文字がスイートスポット。英語より文字あたりの情報量が多いので、英語の800〜1000トークンに相当する。

タイトル埋め込みテクニック

各チャンクの先頭に記事タイトルを付与すると検索精度が大きく上がる。

# frontmatterからタイトルを取得
fm, body = parse_frontmatter(content)
title = fm.get("title", "")

# チャンクにタイトルを付与
chunks = [f"[{title}]\n{chunk}" for chunk in raw_chunks]

これだけで、「Claude Codeのhooksで何ができる?」という質問に対して、hooksの記事が正確にヒットするようになった。

ステップ3: Embedding生成とChromaDB格納

import chromadb
import ollama

def embed_texts(texts):
    """Ollamaでベクトル化"""
    return [
        ollama.embed(model="nomic-embed-text", input=t)["embeddings"][0]
        for t in texts
    ]

# ChromaDB初期化
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="local_docs",
    metadata={"hnsw:space": "cosine"}  # コサイン類似度
)

# 格納
collection.upsert(
    ids=ids,
    embeddings=embeddings,
    documents=chunks,
    metadatas=metadatas,
)

実測: 19ファイル → 487チャンクのインジェスト

📂 19 ファイルを処理中...
  ✅ ai-coding-tool-productivity-gap.md: 8 chunks (0.2s)
  ✅ claude-code-vibe-coding-intro.md: 28 chunks (0.8s)
  ✅ ollama-vs-vllm-rtx4080-benchmark.md: 26 chunks (0.8s)
  ...(19ファイル)

📊 完了: 19 files → 487 chunks
⏱️  Embedding時間: 13.5s (36.1 chunks/s)

36 chunks/秒。RTX 4080でnomic-embed-textは爆速。10,000チャンクでも5分で終わる計算だ。

ステップ4: ハイブリッド検索

ここが本記事の核心。ベクトル検索だけでは日本語の精度が足りない

問題: nomic-embed-textの日本語弱点

nomic-embed-textは英語中心のモデルなので、意味的に同じ内容でも日本語クエリと日本語ドキュメントの類似度が低く出ることがある。

実際に「RTX 4080で一番速いLLMモデルは?」と聞くと、ベンチマーク記事ではなく全く関係ない記事がヒットする問題が発生した。

解決: RRFハイブリッド検索

Reciprocal Rank Fusion (RRF) で、ベクトル検索とキーワード検索の結果を統合する。

def search(query, top_k=5, diversify=True):
    keywords = extract_keywords(query)

    # 1. ベクトル検索(セマンティック)
    vec_results = collection.query(
        query_embeddings=[embed(query)],
        n_results=top_k * 6,
    )

    # 2. キーワード検索(ChromaDB where_document)
    kw_hits = {}
    for kw in keywords[:5]:
        kw_results = collection.query(
            query_embeddings=[embed(query)],
            n_results=top_k * 6,
            where_document={"$contains": kw},
        )
        # マッチ数をカウント
        for hit in kw_results:
            kw_hits[hit.id].kw_match_count += 1

    # 3. RRF融合
    rrf_k = 60
    for rank, hit in enumerate(vec_results):
        scores[hit.id] += 1.0 / (rrf_k + rank + 1)
    for rank, hit in enumerate(sorted(kw_hits)):
        weight = 1.0 + hit.kw_match_count * 0.3
        scores[hit.id] += weight / (rrf_k + rank + 1)

    # 4. 多様性フィルタ(各ソースから最大2チャンク)
    ...

RRFとは

RRF (Reciprocal Rank Fusion) は、複数の検索結果をランクベースで統合するアルゴリズム。スコアの絶対値ではなく順位を使うので、異なるスケールの検索方式を自然に融合できる。

RRFスコア = Σ  1 / (k + rank)

多様性フィルタの効果

チャンク数が多いドキュメント(例: プロンプト集50選 = 110チャンク)が検索結果を独占する問題があった。各ソースから最大2チャンクに制限することで、関連する複数記事の情報をバランスよく取得できるようになった。

改善前:

[1] ai-prompt-collection-50.md (sim: 0.679)
[2] ai-prompt-collection-50.md (sim: 0.657)
[3] ai-prompt-collection-50.md (sim: 0.650)
[4] ai-prompt-collection-50.md (sim: 0.611)
[5] ai-prompt-collection-50.md (sim: 0.611)

改善後:

[1] claude-code-hooks-practical-guide.md (sim: 0.632)
[2] claude-code-vibe-coding-intro.md (sim: 0.631)
[3] ai-prompt-collection-50.md (sim: 0.574)
[4] claude-code-tips-2026.md (sim: 0.560)
[5] claude-code-build-app-in-36min.md (sim: 0.544)

ステップ5: LLMによる回答生成

def query_rag(question):
    hits = search(question)

    # コンテキスト構築
    context = "\n\n---\n\n".join([
        f"[出典: {h['metadata']['source']}]\n{h['document']}"
        for h in hits
    ])

    prompt = f"""以下のコンテキストを参考に、質問に日本語で回答してください。
コンテキストに情報がない場合は「情報が見つかりません」と回答してください。

## コンテキスト
{context}

## 質問
{question}"""

    response = ollama.chat(
        model="qwen3:14b",
        messages=[{"role": "user", "content": prompt}],
        options={"temperature": 0.3, "num_predict": 512},
    )
    return response["message"]["content"]

実際の質問応答例

Q: 知識蒸留のやり方を教えて

🔍 検索: 0.45s | 💬 生成: 9.31s | 📄 出典: distill-local-llm-from-scratch.md

💡 回答:
知識蒸留のやり方について説明します。

1. **準備**
   - 教師モデル(例: 20Bパラメータのgpt-oss:20b)を用意
   - 蒸留モデル(例: 0.5Bパラメータ)を設計

2. **手順**
   - 教師データ生成(12分): 教師モデルでタスクデータを生成
   - 蒸留モデルの訓練(2分20秒): 教師データで訓練
   ...

Q: Claude Codeの生産性を上げるコツを教えて

🔍 検索: 0.07s | 💬 生成: 9.33s
📄 出典: 4つの記事から情報を統合

💡 回答:
1. 明確な指示を与える
2. 出力の確認を徹底する(ファイル構成・import・コメントの3点)
3. ステップ分割で進める

4つの異なる記事から情報を統合して、整理された回答が返ってきた。

ベンチマーク結果

============================================================
  RAG ベンチマーク
============================================================
  Embeddingモデル:  nomic-embed-text
  チャットモデル:    qwen3:14b
  DB内チャンク数:    487
  Embedding速度:    38.2 texts/s
  平均検索時間:      49ms
  E2E応答時間:      9.4s
============================================================
処理 時間 備考
Embedding (1テキスト) 26ms nomic-embed-text、GPU推論
検索 (top-5) 49ms ハイブリッド(ベクトル+キーワード+RRF)
LLM回答生成 約9秒 qwen3:14b、512トークン上限
インジェスト (487チャンク) 13.5秒 36 chunks/s

検索自体は49msで、体感は即座。ボトルネックはLLMの回答生成(9秒)。より軽いモデル(qwen3.5:4b等)を使えば2〜3秒に短縮可能。

ハマりポイントと学び

1. チャンクサイズで精度が激変する

最初1000文字で試したら、無関係なテキストがコンテキストに混入して回答品質が下がった。500文字に変えただけで劇的に改善。

2. タイトル埋め込みは必須テクニック

チャンク単体だと「何の記事の断片か」をEmbeddingが判別できない。タイトルを付与することで、検索精度が体感2割向上した。

3. ベクトル検索だけでは日本語が弱い

nomic-embed-textは英語中心。日本語クエリでの意味検索精度が低いため、キーワード検索とのハイブリッド(RRF融合)が必須だった。

多言語対応のEmbeddingモデル(multilingual-e5-large等)を使えばベクトル検索だけでも精度が出るが、モデルサイズが大きくなる。

4. 大きなドキュメントが検索結果を独占する

110チャンクのプロンプト集が全検索結果をジャックした。多様性フィルタ(各ソース最大2チャンク)で解決。

5. LangChainなしでも150行で書ける

LangChainの学習コスト・デバッグコストを考えると、小規模RAGは素のPythonで書いた方が速い。何が起きているか全部見える安心感は大きい。

応用: あなたのプロジェクトで使うには

社内ドキュメント検索

python rag.py ingest ./company-docs/
python rag.py query "有給休暇の申請方法は?"

コードベース検索

python rag.py ingest ./src/
python rag.py query "認証処理はどのファイルに実装されている?"

チャットボット化

FlaskやFastAPIでAPIを立てれば、Slack botやDiscord botからも利用できる。

from flask import Flask, request, jsonify
app = Flask(__name__)

@app.route("/ask", methods=["POST"])
def ask():
    q = request.json["question"]
    answer = query_rag(q)
    return jsonify({"answer": answer})

まとめ

やったこと 結果
完全ローカルRAGを自作 外部API 0、月額 0円
19記事・487チャンクをインジェスト 13.5秒
ハイブリッド検索(ベクトル+キーワード+RRF) 平均49ms
E2E質問応答 9.4秒(検索49ms + LLM 9秒)
LangChainなしでPython 150行 全処理が透明

RAGは「ベクトルDBにぶち込んで検索するだけ」と思われがちだが、実際にはチャンク設計・タイトル埋め込み・ハイブリッド検索・多様性フィルタなど、精度を出すための工夫が山ほどある

しかしその一つ一つは決して難しくない。本記事のコードは150行で、依存パッケージは2つだけ。まずは自分のドキュメントで動かしてみてほしい。


https://zenn.dev/seeda_yuto/books/local-llm-prompt-engineering

関連記事:

GitHubで編集を提案

Discussion