Back

Ollama + RAG로 내 코드베이스를 이해하는 로컬 AI 코딩 어시스턴트 만들기

방금 ChatGPT한테 사내 코드 디버깅 좀 도와달라고 했더니요. 존재하지도 않는 메서드를 자신만만하게 추천하고, 6개월 전에 폐기한 API 엔드포인트를 참조하고, 한 번도 안 써본 패키지에서 import 하라고 하더라고요. 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

이제 필요한 모델을 받아야 해요. 임베딩용 하나, 코드 생성용 하나:

# 임베딩 모델 (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토큰씩 잘라라"고 하는데, 코드에 이걸 적용하면 완전 망해요. 함수가 두 청크로 쪼개지면 검색해봤자 쓸모가 없고, 메서드 빠진 클래스 정의는 그냥 쓰레기죠.

그래서 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 기반 청킹은 메서드당 하나씩 세 개 청크를 만들어요. 각각에 클래스 이름이랑 파일 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(); // Step 1: 코드베이스 청킹 console.log(`코드베이스 청킹 중: ${targetDir}`); const chunks = await chunkCodebase(targetDir); console.log(`${chunks.length}개 청크 생성 완료`); // Step 2: 임베딩 및 저장 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 시리즈 맥에서의 인덱싱 성능 참고치:

코드베이스 규모파일 수청크 수인덱싱 시간
소규모 (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한테 한 번 더 걸러달라고 하면 false positive을 확 줄일 수 있어요:

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 4090CPU (7950X)
Qwen 3.5 8B0.8초0.3초3.2초
Qwen 3.5 14B1.5초0.5초8.1초
DeepSeek Coder V2 33B3.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 조합이 단순한 방식 대비 정확도를 거의 두 배로 올려줘요.

자주 발생하는 함정과 해결법

함정 1: 임베딩 모델 불일치

nomic-embed-text로 임베딩했는데 mxbai-embed-large로 쿼리하면 결과가 완전 엉망이에요. 인덱싱과 쿼리에 동일한 임베딩 모델을 써야 해요. 당연한 소리 같지만, 개발 중에 모델을 바꿀 때 가장 흔하게 발생하는 실수예요.

함정 2: 청크 크기의 양극단

청크가 너무 작으면(한 줄 단위) 컨텍스트를 잃어요. 너무 크면(파일 전체) 시맨틱 신호가 희석돼요. 코드의 경우 청크당 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를 편하게 담을 수 있어요. 코드 청크로 치면 810개 정도예요. 이 이상 넘기면 품질이 떨어져요.

로컬 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로 라우팅하면 최고 품질을 얻을 수 있어요. 코드 컨텍스트는 로컬에 남고, 조립된 프롬프트(관련 스니펫 포함)만 클라우드로 나가는 구조예요.

다음 단계

여기까지 따라왔으면 기반은 탄탄하게 잡은 거예요. 더 나아가고 싶다면:

  1. tree-sitter 파싱 붙이기: 정규식 방식 대신 제대로 된 AST 파싱으로 모든 언어에서 정확하게 청킹할 수 있어요.
  2. 에디터 통합: VS Code 확장이나 Neovim 플러그인 만들어서 코딩하다가 바로 질문 던질 수 있게요.
  3. git 컨텍스트 활용: 최근 diff, blame 정보, PR 설명까지 검색 메타데이터에 넣으면 훨씬 똑똑해져요.
  4. 에이전틱 루프: Function Calling 붙여서 어시스턴트가 스스로 검색하고, 파일 읽고, 테스트까지 돌리게 만들어보세요.
  5. 임베딩 파인튜닝: Contrastive Learning으로 내 코드에 맞게 임베딩 모델을 튜닝하면 검색 정확도 15~20% 더 뽑을 수 있어요.

도구는 다 있고, 모델도 충분히 좋고, 인프라는 노트북 한 대면 끝이에요. 주말 하루만 투자하면 내 코드를 진짜로 이해하는 프라이빗 AI 어시스턴트를 갖게 됩니다.

AIOllamaRAGLLM로컬 AI코딩 어시스턴트ChromaDB임베딩프라이버시개발자 도구

관련 도구 둘러보기

Pockit의 무료 개발자 도구를 사용해 보세요