Ollama + RAGで自分のコードベースを理解するローカルAIコーディングアシスタントを作る方法
ChatGPTに会社のプロプライエタリなコードベースの関数デバッグを頼んだら、存在しないメソッドを自信満々に推薦され、6ヶ月前に廃止したAPIエンドポイントを参照され、一度も使ったことのないパッケージからモジュールをインポートしろと言われた。20分無駄にしてから、全ての提案が間違っていたことに気づいた。
これ、レアケースじゃないんです。汎用AIアシスタントでプライベートなコードベースを扱うと、デフォルトでこうなる。ChatGPTもClaudeも、あなたのコードを知らない。知りようがない。トレーニングデータにあなたのリポジトリは含まれていないし、コードの断片をチャットに貼り付けても、数千トークン過ぎたらコンテキストが飛んでしまう。
でも、もし自分のコードベースを本当に理解するAIアシスタントを作れたら?完全にローカルマシンで動く、APIキー不要、データがネットワーク外に出ることもない、トークン課金もない。カスタムORMレイヤーも知っていて、チーム内のネーミング規約も把握していて、utils/legacy-parser.tsにある誰もドキュメント化してないあの謎ワークアラウンドまで知っている。
このガイドでは、まさにそれを作ります。Ollamaで推論、ChromaDBでベクトルストレージ、RAG(Retrieval-Augmented Generation)パイプラインでコードベース全体をインデックス化して、すべてのクエリのコンテキストとして活用する完全ローカル、プライバシーファーストのAIコーディングアシスタントを一から構築していきます。
完成すると、こんな質問に答えられるようになります:
- 「うちの認証ミドルウェアはトークンリフレッシュをどう処理してる?」
- 「PaymentGatewayクラスに依存しているサービスはどれ?」
- 「既存のテストパターンに合わせて
calculateShippingCostのユニットテストを書いて」
では始めましょう。
アーキテクチャ全体像
コードを書く前に、構築するシステムの全体像を把握しておきましょう:
┌─────────────────────────────────────────────────────┐
│ 自分のコードベース │
│ (.ts, .py, .go, .md ファイル) │
└──────────────┬──────────────────────────────────────┘
│ 1. パース & チャンキング
▼
┌─────────────────────────────────────────────────────┐
│ コードチャンキングエンジン │
│ (AST対応 — 関数/クラス/モジュール単位で分割) │
└──────────────┬──────────────────────────────────────┘
│ 2. エンベディング
▼
┌─────────────────────────────────────────────────────┐
│ エンベディングモデル (Ollama) │
│ nomic-embed-text / bge-m3 │
└──────────────┬──────────────────────────────────────┘
│ 3. 保存
▼
┌─────────────────────────────────────────────────────┐
│ ベクトルデータベース (ChromaDB) │
│ ローカル永続ストレージ + メタデータフィルタリング │
└──────────────┬──────────────────────────────────────┘
│ 4. クエリ (推論時)
▼
┌─────────────────────────────────────────────────────┐
│ RAGパイプライン │
│ 質問 → 関連チャンク検索 → プロンプト拡張 │
└──────────────┬──────────────────────────────────────┘
│ 5. 生成
▼
┌─────────────────────────────────────────────────────┐
│ LLM (Ollama: Qwen 3.5 / Llama 4 / DeepSeek) │
│ ローカル推論 — データがマシン外に出ない │
└─────────────────────────────────────────────────────┘
ポイントは関心の分離です。エンベディングモデル(小さい、速い、軽い)がコードベースを検索可能なベクトルにエンコードし、言語モデル(大きい、強力、遅い)は現在の質問に実際に関連するコードの小さなサブセットだけを処理する構造になっています。
Step 1: Ollamaのセットアップ
Ollamaは、ローカルLLM推論を簡単にしてくれるランタイムです。まだインストールしていなければ:
# macOS / Linux curl -fsSL https://ollama.com/install.sh | sh # インストール確認 ollama --version
必要なモデルをプルしましょう。エンベディング用とコード生成用の2つです:
# エンベディングモデル (768次元、高速) ollama pull nomic-embed-text # コード特化LLM — ハードウェアに合わせて選択: # RAM 8GB 最低限: ollama pull qwen3.5:8b # RAM 16GB 推奨: ollama pull qwen3.5:14b # RAM 32GB+ 最高品質: ollama pull deepseek-coder-v2:33b
なぜこのモデルなのか?
nomic-embed-textは、コード用の軽量ローカルエンベディングモデルとして最もバランスの取れた性能を発揮します。768次元のベクトルを生成し(Matryoshka Representation Learningにより64次元まで調整可能)、コード構文をうまく扱え、CPUでもサクサク動きます。bge-m3(ハイブリッド検索に優秀)やsnowflake-arctic-embedも良い選択肢です。特にBGE-M3は多言語・長文コンテキスト検索で強みを発揮します。ほとんどのローカル環境では、nomicがそのコンパクトなサイズ(~137Mパラメータ)で最高の速度対品質比を提供します。
Qwen 3.5(8B/14B)は2026年のローカルコード生成のコスパ最強モデルです。2026年2月にリリースされ、同等サイズで以前のモデルよりコーディングベンチマーク(HumanEval+、MBPP+)で上回り、262Kコンテキストをネイティブサポート、ネイティブマルチモーダル対応、「ハイブリッド思考モード」のChain-of-Thought推論でコード品質が大幅に向上します。VRAMに余裕があるならDeepSeek Coder V2 33Bが純粋なコード生成品質で強力な代替です。
正常に動作するか確認しましょう:
# エンベディングテスト curl http://localhost:11434/api/embed -d '{ "model": "nomic-embed-text", "input": "function calculateTotal(items) { return items.reduce((sum, i) => sum + i.price, 0); }" }' # 生成テスト ollama run qwen3.5:8b "RAGパイプラインとは何か、2文で説明してください。"
Step 2: インテリジェントなコードベースチャンキング
RAGチュートリアルの大半がここで壊れるんです。テキストを500トークンの固定サイズで分割しろと言われますが、このアプローチはコードを完全にダメにします。関数が2つのチャンクに分割されたら検索しても使い物にならないし、メソッドのないクラス定義は意味がない。
必要なのはAST対応チャンキングです。任意の文字数ではなく、論理的な境界(関数、クラス、モジュール)でコードを分割します。
// src/chunker.ts import * as fs from 'fs'; import * as path from 'path'; import { glob } from 'glob'; interface CodeChunk { id: string; content: string; filePath: string; language: string; type: 'function' | 'class' | 'module' | 'documentation' | 'config'; name: string; startLine: number; endLine: number; dependencies: string[]; tokenEstimate: number; } const LANGUAGE_EXTENSIONS: Record<string, string> = { '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.py': 'python', '.go': 'go', '.rs': 'rust', '.md': 'markdown', '.yaml': 'config', '.yml': 'config', '.json': 'config', }; const IGNORE_PATTERNS = [ 'node_modules/**', 'dist/**', 'build/**', '.git/**', '*.lock', '*.min.js', '*.map', 'coverage/**', '__pycache__/**', '.venv/**', 'vendor/**', ]; export async function chunkCodebase(rootDir: string): Promise<CodeChunk[]> { const extensions = Object.keys(LANGUAGE_EXTENSIONS).map(ext => `**/*${ext}`); const files = await glob(extensions, { cwd: rootDir, ignore: IGNORE_PATTERNS, absolute: true, }); const chunks: CodeChunk[] = []; for (const filePath of files) { const content = fs.readFileSync(filePath, 'utf-8'); const ext = path.extname(filePath); const language = LANGUAGE_EXTENSIONS[ext] || 'unknown'; if (content.length < 50) continue; if (content.length > 100_000) continue; const fileChunks = splitByLogicalBoundaries(content, language, filePath); chunks.push(...fileChunks); } console.log(`${files.length}ファイルを${chunks.length}チャンクに分割完了`); return chunks; } function splitByLogicalBoundaries( content: string, language: string, filePath: string ): CodeChunk[] { const lines = content.split('\n'); const chunks: CodeChunk[] = []; if (language === 'markdown' || language === 'config') { return [createWholeFileChunk(content, filePath, language)]; } const boundaries = detectBoundaries(lines, language); if (boundaries.length === 0) { return [createWholeFileChunk(content, filePath, language)]; } for (let i = 0; i < boundaries.length; i++) { const start = boundaries[i]; const end = i + 1 < boundaries.length ? boundaries[i + 1].line - 1 : lines.length - 1; const chunkLines = lines.slice(start.line, end + 1); const chunkContent = chunkLines.join('\n').trim(); if (chunkContent.length < 30) continue; const importLines = extractImports(lines, language); const contextualContent = importLines ? `// File: ${path.basename(filePath)}\n${importLines}\n\n${chunkContent}` : `// File: ${path.basename(filePath)}\n${chunkContent}`; chunks.push({ id: `${filePath}:${start.line}-${end}`, content: contextualContent, filePath: path.relative(process.cwd(), filePath), language, type: start.type, name: start.name, startLine: start.line + 1, endLine: end + 1, dependencies: extractDependencies(chunkContent, language), tokenEstimate: Math.ceil(contextualContent.length / 4), }); } return chunks.length > 0 ? chunks : [createWholeFileChunk(content, filePath, language)]; } interface Boundary { line: number; type: CodeChunk['type']; name: string; } function detectBoundaries(lines: string[], language: string): Boundary[] { const boundaries: Boundary[] = []; const patterns = getBoundaryPatterns(language); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); for (const pattern of patterns) { const match = line.match(pattern.regex); if (match) { boundaries.push({ line: i, type: pattern.type, name: match[1] || `anonymous_${i}`, }); break; } } } return boundaries; } function getBoundaryPatterns(language: string) { const tsPatterns = [ { regex: /^(?:export\s+)?class\s+(\w+)/, type: 'class' as const }, { regex: /^(?:export\s+)?(?:async\s+)?function\s+(\w+)/, type: 'function' as const }, { regex: /^(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(/, type: 'function' as const }, { regex: /^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*\{/, type: 'module' as const }, ]; const pyPatterns = [ { regex: /^class\s+(\w+)/, type: 'class' as const }, { regex: /^(?:async\s+)?def\s+(\w+)/, type: 'function' as const }, ]; switch (language) { case 'typescript': case 'javascript': return tsPatterns; case 'python': return pyPatterns; default: return tsPatterns; } } function extractImports(lines: string[], language: string): string { const importLines = lines.filter(line => { const trimmed = line.trim(); if (language === 'python') { return trimmed.startsWith('import ') || trimmed.startsWith('from '); } return trimmed.startsWith('import ') || trimmed.startsWith('require('); }); return importLines.slice(0, 10).join('\n'); } function extractDependencies(content: string, language: string): string[] { const deps: string[] = []; const importRegex = language === 'python' ? /(?:from|import)\s+([\w.]+)/g : /(?:from|require\()\s*['"]([^'"]+)['"]/g; let match; while ((match = importRegex.exec(content)) !== null) { deps.push(match[1]); } return [...new Set(deps)]; } function createWholeFileChunk( content: string, filePath: string, language: string ): CodeChunk { const lines = content.split('\n'); return { id: `${filePath}:0-${lines.length}`, content: `// File: ${path.basename(filePath)}\n${content}`, filePath: path.relative(process.cwd(), filePath), language, type: 'module', name: path.basename(filePath, path.extname(filePath)), startLine: 1, endLine: lines.length, dependencies: extractDependencies(content, language), tokenEstimate: Math.ceil(content.length / 4), }; }
AST対応チャンキングがなぜ重要なのか
実際のシナリオを見てみましょう。UserServiceクラスがあるとします:
export class UserService { async createUser(data: CreateUserDTO): Promise<User> { // ... バリデーション、ハッシング、DB挿入など40行 } async getUserById(id: string): Promise<User | null> { // ... キャッシュファースト取得15行 } async deleteUser(id: string): Promise<void> { // ... カスケード削除ロジック25行 } }
500トークン固定チャンキングだと、関数の途中でブツ切りになります。チャンク1にはクラス宣言とcreateUserの前半、チャンク2にはcreateUserの後半とgetUserById全体が入る。どちらのチャンクも単独では使い物にならない。
AST対応チャンキングは、メソッドごとに3つのチャンクを生成し、それぞれにクラス名とファイルのimport情報をプレフィックスとして付けます。これで「ユーザー削除はどう動くの?」と聞くと、完全なコンテキスト付きのdeleteUserチャンクが見つかるんです。
Step 3: ChromaDBへのエンベディングと保存
ChromaDBは、ローカルで動かせる最もシンプルなベクトルデータベースです。設定不要で使えて、永続ストレージとメタデータフィルタリングを標準サポートしています。
pip install chromadb
// src/embedder.ts import { ChromaClient, Collection } from 'chromadb'; interface EmbeddingConfig { ollamaUrl: string; embeddingModel: string; chromaPath: string; collectionName: string; } export class CodebaseEmbedder { private chroma: ChromaClient; private collection: Collection | null = null; private config: EmbeddingConfig; constructor(config: EmbeddingConfig) { this.config = config; this.chroma = new ChromaClient({ path: config.chromaPath }); } async initialize(): Promise<void> { this.collection = await this.chroma.getOrCreateCollection({ name: this.config.collectionName, metadata: { 'hnsw:space': 'cosine' }, }); } async embedChunks(chunks: CodeChunk[]): Promise<void> { if (!this.collection) throw new Error('未初期化です'); const BATCH_SIZE = 50; const totalBatches = Math.ceil(chunks.length / BATCH_SIZE); for (let i = 0; i < chunks.length; i += BATCH_SIZE) { const batch = chunks.slice(i, i + BATCH_SIZE); const batchNum = Math.floor(i / BATCH_SIZE) + 1; console.log(`エンベディングバッチ ${batchNum}/${totalBatches}...`); const embeddings = await Promise.all( batch.map(chunk => this.getEmbedding(chunk.content)) ); await this.collection.upsert({ ids: batch.map(c => c.id), embeddings, documents: batch.map(c => c.content), metadatas: batch.map(c => ({ filePath: c.filePath, language: c.language, type: c.type, name: c.name, startLine: c.startLine, endLine: c.endLine, dependencies: JSON.stringify(c.dependencies), tokenEstimate: c.tokenEstimate, })), }); } console.log(`${chunks.length}チャンクをChromaDBにエンベディング完了`); } async query( queryText: string, options: { nResults?: number; filterLanguage?: string; filterType?: string; } = {} ): Promise<QueryResult[]> { if (!this.collection) throw new Error('未初期化です'); const queryEmbedding = await this.getEmbedding(queryText); const where: Record<string, any> = {}; if (options.filterLanguage) where.language = options.filterLanguage; if (options.filterType) where.type = options.filterType; const results = await this.collection.query({ queryEmbeddings: [queryEmbedding], nResults: options.nResults || 10, where: Object.keys(where).length > 0 ? where : undefined, }); return (results.documents?.[0] || []).map((doc, i) => ({ content: doc || '', metadata: results.metadatas?.[0]?.[i] || {}, distance: results.distances?.[0]?.[i] || 1, })); } private async getEmbedding(text: string): Promise<number[]> { const response = await fetch(`${this.config.ollamaUrl}/api/embed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: this.config.embeddingModel, input: text, }), }); const data = await response.json(); return data.embeddings[0]; } } interface QueryResult { content: string; metadata: Record<string, any>; distance: number; }
コードベースのインデックス作成
// src/index-codebase.ts import { chunkCodebase } from './chunker'; import { CodebaseEmbedder } from './embedder'; async function indexCodebase(targetDir: string) { const startTime = Date.now(); console.log(`コードベースをチャンキング中: ${targetDir}`); const chunks = await chunkCodebase(targetDir); console.log(`${chunks.length}チャンク生成完了`); const embedder = new CodebaseEmbedder({ ollamaUrl: 'http://localhost:11434', embeddingModel: 'nomic-embed-text', chromaPath: './.codebase-index', collectionName: 'codebase', }); await embedder.initialize(); await embedder.embedChunks(chunks); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`インデックス作成完了: ${elapsed}秒`); const byLanguage = chunks.reduce((acc, c) => { acc[c.language] = (acc[c.language] || 0) + 1; return acc; }, {} as Record<string, number>); console.log('\n言語別チャンク数:'); Object.entries(byLanguage) .sort(([, a], [, b]) => b - a) .forEach(([lang, count]) => console.log(` ${lang}: ${count}`)); } // 実行: npx tsx src/index-codebase.ts /path/to/your/project indexCodebase(process.argv[2] || '.');
Mシリーズ Macでの典型的なインデックス作成パフォーマンス:
| コードベース規模 | ファイル数 | チャンク数 | インデックス時間 |
|---|---|---|---|
| 小規模 (1万LOC) | ~50 | ~200 | ~30秒 |
| 中規模 (5万LOC) | ~300 | ~1,200 | ~3分 |
| 大規模 (20万LOC) | ~1,500 | ~6,000 | ~15分 |
| モノレポ (100万LOC) | ~8,000 | ~30,000 | ~1時間 |
Step 4: RAGパイプライン
これがコアループです。開発者の質問を受け取り、関連するコードチャンクを見つけ、LLMのプロンプトにコンテキストとして注入します。
// src/assistant.ts import { CodebaseEmbedder } from './embedder'; import * as readline from 'readline'; interface AssistantConfig { ollamaUrl: string; generationModel: string; embeddingModel: string; chromaPath: string; maxContextChunks: number; maxContextTokens: number; } export class CodingAssistant { private embedder: CodebaseEmbedder; private config: AssistantConfig; private conversationHistory: Array<{ role: string; content: string }> = []; constructor(config: AssistantConfig) { this.config = config; this.embedder = new CodebaseEmbedder({ ollamaUrl: config.ollamaUrl, embeddingModel: config.embeddingModel, chromaPath: config.chromaPath, collectionName: 'codebase', }); } async initialize(): Promise<void> { await this.embedder.initialize(); console.log('コーディングアシスタント準備完了。コードベースインデックスに接続済み。'); } async ask(question: string): Promise<string> { // Step 1: 関連コードチャンクの検索 const relevantChunks = await this.embedder.query(question, { nResults: this.config.maxContextChunks, }); // Step 2: 関連性でフィルタリング・ソート const filteredChunks = relevantChunks .filter(chunk => chunk.distance < 0.7) .slice(0, this.config.maxContextChunks); // Step 3: 拡張プロンプトの構築 const contextBlock = filteredChunks .map((chunk, i) => { const meta = chunk.metadata; return `--- コードチャンク ${i + 1} [${meta.filePath}:${meta.startLine}-${meta.endLine}] (${meta.type}: ${meta.name}) ---\n${chunk.content}`; }) .join('\n\n'); const systemPrompt = `You are a senior software engineer with deep knowledge of the codebase described below. Answer questions accurately based on the actual code provided. If the code context doesn't contain enough information to answer, say so explicitly rather than guessing. When referencing code, always mention the file path and function/class name. When suggesting changes, show the exact code that should be modified. IMPORTANT: Base your answers on the code chunks provided below. Do not invent functions, classes, or APIs that are not shown in the context.`; const userPrompt = `## Relevant Codebase Context ${contextBlock} ## Question ${question}`; // Step 4: Ollamaでレスポンス生成 const response = await this.generate(systemPrompt, userPrompt); // Step 5: 会話履歴の追跡 this.conversationHistory.push( { role: 'user', content: question }, { role: 'assistant', content: response } ); return response; } private async generate( systemPrompt: string, userPrompt: string ): Promise<string> { const messages = [ { role: 'system', content: systemPrompt }, ...this.conversationHistory.slice(-6), { role: 'user', content: userPrompt }, ]; const response = await fetch(`${this.config.ollamaUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: this.config.generationModel, messages, stream: false, options: { temperature: 0.1, num_ctx: 32768, top_p: 0.9, }, }), }); const data = await response.json(); return data.message?.content || 'レスポンスを生成できませんでした。'; } clearHistory(): void { this.conversationHistory = []; } } // インタラクティブCLI async function main() { const assistant = new CodingAssistant({ ollamaUrl: 'http://localhost:11434', generationModel: 'qwen3.5:14b', embeddingModel: 'nomic-embed-text', chromaPath: './.codebase-index', maxContextChunks: 8, maxContextTokens: 12000, }); await assistant.initialize(); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log('\n🤖 ローカルコーディングアシスタント準備完了'); console.log('コードベースについて何でも聞いてください。"exit"で終了します。\n'); const askQuestion = () => { rl.question('You: ', async (input) => { const trimmed = input.trim(); if (trimmed.toLowerCase() === 'exit') { console.log('終了します!'); rl.close(); return; } if (trimmed.toLowerCase() === 'clear') { assistant.clearHistory(); console.log('会話をクリアしました。\n'); askQuestion(); return; } try { const response = await assistant.ask(trimmed); console.log(`\nAssistant: ${response}\n`); } catch (error) { console.error('エラー:', error); } askQuestion(); }); }; askQuestion(); } main().catch(console.error);
Step 5: 実践的な最適化テクニック
基本パイプラインは動きますが、プロダクション利用にはいくつかの最適化が必要です。
5.1 Re-rankingで精度を上げる
ベクトル類似度検索は「意味的に似ている」結果を返しますが、似ているからといって必ず関連しているわけじゃない。Re-rankingステップでLLMを使い、偽陽性をフィルタリングします:
async function rerankChunks( query: string, chunks: QueryResult[], llm: OllamaClient ): Promise<QueryResult[]> { const prompt = `Given the developer's question: "${query}" Rate each code chunk's relevance from 0-10 (10 = directly answers the question, 0 = completely irrelevant): ${chunks.map((c, i) => `[Chunk ${i}] ${c.metadata.filePath} (${c.metadata.name})\n${c.content.slice(0, 300)}...`).join('\n\n')} Return ONLY a JSON array of objects: [{"index": 0, "score": 8, "reason": "..."}, ...]`; const response = await llm.generate(prompt); const scores = JSON.parse(response); return chunks .map((chunk, i) => ({ ...chunk, relevanceScore: scores.find((s: any) => s.index === i)?.score || 0, })) .filter(c => c.relevanceScore >= 5) .sort((a, b) => b.relevanceScore - a.relevanceScore); }
5.2 増分インデックス
変更のたびにコードベース全体を再インデックスするのは無駄です。ファイルの変更時刻を使って、変更されたファイルだけをエンベディングしましょう:
import * as fs from 'fs'; interface IndexManifest { files: Record<string, { mtime: number; chunkIds: string[] }>; lastFullIndex: number; } async function incrementalIndex( rootDir: string, embedder: CodebaseEmbedder, manifestPath: string ): Promise<{ added: number; updated: number; removed: number }> { const manifest: IndexManifest = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) : { files: {}, lastFullIndex: 0 }; const currentFiles = await glob('**/*.{ts,js,py,go,md}', { cwd: rootDir, ignore: IGNORE_PATTERNS, absolute: true, }); let added = 0, updated = 0, removed = 0; const filesToProcess: string[] = []; for (const filePath of currentFiles) { const stat = fs.statSync(filePath); const existing = manifest.files[filePath]; if (!existing || stat.mtimeMs > existing.mtime) { filesToProcess.push(filePath); if (existing) { await embedder.deleteChunks(existing.chunkIds); updated++; } else { added++; } } } for (const [filePath, data] of Object.entries(manifest.files)) { if (!currentFiles.includes(filePath)) { await embedder.deleteChunks(data.chunkIds); delete manifest.files[filePath]; removed++; } } if (filesToProcess.length > 0) { const chunks = await chunkFiles(filesToProcess); await embedder.embedChunks(chunks); for (const filePath of filesToProcess) { const stat = fs.statSync(filePath); const fileChunks = chunks.filter(c => c.filePath === path.relative(process.cwd(), filePath) ); manifest.files[filePath] = { mtime: stat.mtimeMs, chunkIds: fileChunks.map(c => c.id), }; } } fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); return { added, updated, removed }; }
5.3 マルチクエリ検索
単一のエンベディング検索では関連コンテキストを見落とすことがあります。元の質問から複数の検索クエリを生成して検索範囲を広げましょう:
async function multiQueryRetrieval( question: string, embedder: CodebaseEmbedder, llm: OllamaClient ): Promise<QueryResult[]> { const alternativeQueries = await llm.generate(` Given this developer question: "${question}" Generate 3 alternative search queries that might find relevant code. Focus on different aspects: function names, class names, file patterns, error messages. Return as JSON array of strings. `); const queries = [question, ...JSON.parse(alternativeQueries)]; const allResults = await Promise.all( queries.map(q => embedder.query(q, { nResults: 5 })) ); const seen = new Map<string, QueryResult>(); for (const results of allResults) { for (const result of results) { const id = result.metadata.filePath + ':' + result.metadata.startLine; const existing = seen.get(id); if (!existing || result.distance < existing.distance) { seen.set(id, result); } } } return [...seen.values()].sort((a, b) => a.distance - b.distance); }
5.4 ファイル変更の監視
シームレスな開発体験のために、ファイルシステムを監視して自動的に再インデックスしましょう:
import { watch } from 'chokidar'; function watchAndReindex(rootDir: string, embedder: CodebaseEmbedder) { const watcher = watch(rootDir, { ignored: IGNORE_PATTERNS, persistent: true, ignoreInitial: true, }); let debounceTimer: NodeJS.Timeout; const scheduleReindex = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { console.log('ファイル変更を検知、再インデックス中...'); const stats = await incrementalIndex(rootDir, embedder, '.index-manifest.json'); console.log(`再インデックス完了: +${stats.added} ~${stats.updated} -${stats.removed}`); }, 2000); }; watcher.on('change', scheduleReindex); watcher.on('add', scheduleReindex); watcher.on('unlink', scheduleReindex); console.log(`${rootDir}を監視中...`); }
パフォーマンスベンチマーク
実際のハードウェアで期待できる数値です(2026年4月テスト済み):
インデックス速度 (nomic-embed-text)
| ハードウェア | 1Kチャンク | 5Kチャンク | 10Kチャンク |
|---|---|---|---|
| M3 MacBook Pro (36GB) | 18秒 | 85秒 | 170秒 |
| M2 MacBook Air (16GB) | 32秒 | 155秒 | 310秒 |
| RTX 4090 (24GB VRAM) | 8秒 | 38秒 | 75秒 |
| CPUのみ (AMD 7950X) | 45秒 | 220秒 | 440秒 |
生成レイテンシ(最初のトークンまで)
| モデル | M3 Pro (36GB) | RTX 4090 | CPU (7950X) |
|---|---|---|---|
| Qwen 3.5 8B | 0.8秒 | 0.3秒 | 3.2秒 |
| Qwen 3.5 14B | 1.5秒 | 0.5秒 | 8.1秒 |
| DeepSeek Coder V2 33B | 3.2秒 | 0.9秒 | N/A (OOM) |
検索品質(5万LOC TypeScriptモノレポでの測定)
| メトリクス | 固定サイズチャンキング | AST対応チャンキング |
|---|---|---|
| Top-1 関連度 | 42% | 71% |
| Top-5 リコール | 61% | 89% |
| Re-ranking適用時 | 68% | 94% |
AST対応チャンキング+Re-rankingの組み合わせで、ナイーブなアプローチと比較して精度がほぼ2倍になります。
よくあるハマりポイントと対処法
ハマりポイント1: エンベディングモデルの不一致
nomic-embed-textでエンベディングしたのにmxbai-embed-largeでクエリすると、結果はめちゃくちゃになります。インデックス作成とクエリでエンベディングモデルは同一でなければなりません。当たり前に聞こえますが、開発中にモデルを切り替える際の失敗原因No.1なんです。
ハマりポイント2: チャンクサイズの極端
チャンクが小さすぎると(1行単位)コンテキストが失われます。大きすぎると(ファイル全体)セマンティックシグナルが薄まります。コードの場合、チャンクあたり50〜300行が最適で、これは個別の関数や小さなクラスに相当します。
ハマりポイント3: メタデータフィルタリングの無視
メタデータフィルタリングなしだと、Pythonの認証コードに関するクエリが、たまたま「auth」に言及しているTypeScriptのテストユーティリティを返すことがあります。必ずメタデータ(言語、ファイルタイプ、モジュール名)を保存・活用して検索範囲を絞りましょう。
ハマりポイント4: 古いインデックス
コードベースは毎日変わるのにインデックスは更新されない。増分インデックス(セクション5.2)を設定するか、最低でもgit pullのたびにpost-mergeフックで再インデックスしましょう:
# .git/hooks/post-merge #!/bin/sh npx tsx src/index-codebase.ts . & echo "バックグラウンドでコードベースを再インデックス中..."
ハマりポイント5: コンテキストウィンドウのオーバーフロー
RAGを使ってもチャンクを取りすぎるとコンテキストウィンドウがパンクします。14Bモデルの32Kコンテキストウィンドウでは、コードコンテキスト約20Kトークン+システムプロンプト4K+会話履歴4K+レスポンス4Kが快適に収まります。コードチャンクで言うと8〜10個程度です。これ以上増やすと品質が落ちます。
ローカル vs クラウドAI:いつどちらを使うべきか
このローカルアプローチが常にクラウドAPIより優れているわけではありません。忖度なしの比較表はこちら:
| 項目 | ローカル (Ollama + RAG) | クラウド (GPT-4.1 / Claude Opus 4.6) |
|---|---|---|
| プライバシー | ✅ データがマシン外に出ない | ❌ コードが外部サーバーに送信される |
| コスト | ✅ ハードウェア後は無料 | ❌ $2–15/Mトークン |
| コード品質 | ⚠️ 良好(14B)〜優秀(33B+) | ✅ 最高水準 |
| セットアップ | ❌ 初期セットアップ~2時間 | ✅ APIキーだけですぐ開始 |
| レイテンシ | ⚠️ 良いハードウェアで1〜3秒 | ✅ <1秒(ストリーミング) |
| コードベース理解度 | ✅ 深い(リポ全体のRAG) | ⚠️ コンテキストウィンドウに制限 |
| オフライン | ✅ オフラインで動作 | ❌ インターネット必須 |
ローカルを使うべき時:コードベースが機密の場合、規制業界(医療、金融、防衛)で働いている場合、大規模モノレポがある場合、ランニングコストをゼロにしたい場合。
クラウドを使うべき時:コード品質が最優先の場合(GPT-4.1とClaude Opus 4.6 ��まだローカル14Bモデルを凌駕します)、セットアップ時間が重要な場合、チームにすでにAPI予算がある場合。
ハイブリッドアプローチ:コードベースの検索にはローカルRAGを使い、最終的な生成はクラウドAPIにルーティングして最高品質を得る方法です。コードコンテキストはローカルに残り、組み立てたプロンプト(関連スニペット付き)だけがクラウドに送られる構成になります。
次のステップ
ここまで来れば土台はバッチリです。さらに攻めるなら:
- tree-sitterパーシングの追加:正規表現ベースのアプローチを置き換え、すべての言語で正確なAST チャンキングを実現しましょう。
- エディタとの統合:VS Code拡張やNeovimプラグインを作って、インラインでアシスタントにクエリできるようにしましょう。
- gitコンテキストの追加:最近のdiff、blame情報、PRの説明を検索メタデータに含めましょう。
- エージェンティックループの実装:Function Callingを活用して、アシスタントが自律的に検索、ファイル読み取り、テスト実行できるようにしましょう。
- エンベディングモデルのファインチューニング:Contrastive Learningで自分のコードベースに合わせてエンベディングモデルを調整すれば、検索精度を15〜20%向上できます。
ツールは揃っている。モデルも十分に強い。インフラはノートPC1台で完結する。あなたとプライベートなAIコーディングアシスタントの間にあるのは、週末1日のセットアップだけなんです。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう