Back

Cómo construir un asistente de código con IA local usando Ollama, RAG y tu propio código fuente

Recién le pediste a ChatGPT que te ayude a debuggear una función del código de tu empresa. Te recomendó con total confianza un método que no existe, referenció un endpoint que tu equipo deprecó hace seis meses y te dijo que importes un módulo de un paquete que jamás usaste. Perdiste 20 minutos al pedo antes de darte cuenta de que todo estaba mal.

Y no, no es un caso aislado. Es lo que pasa siempre cuando usás asistentes de IA genéricos para código privado. No conocen tu código — no pueden conocerlo. Tus repos nunca estuvieron en los datos de entrenamiento, y aunque pegues fragmentos en el chat, el contexto se pierde después de unos miles de tokens.

Pero, ¿y si pudieras armar un asistente que de verdad entienda tu base de código? Uno que corra 100% en tu máquina. Sin API keys, sin datos que salgan de tu red, sin facturación por token. Uno que conozca tu ORM custom, las convenciones del equipo y ese workaround raro en utils/legacy-parser.ts que nadie documentó.

Eso es exactamente lo que vamos a construir. Un asistente de código completamente local, potenciado por Ollama para la inferencia, ChromaDB para vectores y un pipeline RAG que indexa todo tu código y lo usa como contexto en cada consulta.

Al final, vas a tener un sistema funcional capaz de responder preguntas como:

  • "¿Cómo maneja nuestro middleware de autenticación la renovación de tokens?"
  • "¿Qué servicios dependen de la clase PaymentGateway?"
  • "Escribí un test unitario para calculateShippingCost usando los patrones de testing que ya tenemos."

Empecemos.

Arquitectura general

Antes de escribir código, entendamos el sistema que vamos a construir:

┌─────────────────────────────────────────────────────┐
│                Tu base de código                     │
│  (archivos .ts, .py, .go, .md)                      │
└──────────────┬──────────────────────────────────────┘
               │  1. Parseo y Chunking
               ▼
┌─────────────────────────────────────────────────────┐
│            Motor de Chunking de Código               │
│  (División por AST: función/clase/módulo)            │
└──────────────┬──────────────────────────────────────┘
               │  2. Embedding
               ▼
┌─────────────────────────────────────────────────────┐
│          Modelo de Embedding (Ollama)                │
│  nomic-embed-text / bge-m3               │
└──────────────┬──────────────────────────────────────┘
               │  3. Almacenamiento
               ▼
┌─────────────────────────────────────────────────────┐
│         Base de datos vectorial (ChromaDB)           │
│  Almacenamiento local persistente + filtro metadata │
└──────────────┬──────────────────────────────────────┘
               │  4. Consulta (en tiempo de inferencia)
               ▼
┌─────────────────────────────────────────────────────┐
│              Pipeline RAG                            │
│  Pregunta → Buscar chunks relevantes → Enriquecer   │
└──────────────┬──────────────────────────────────────┘
               │  5. Generación
               ▼
┌─────────────────────────────────────────────────────┐
│        LLM (Ollama: Qwen 3.5 / Llama 4 / DeepSeek)   │
│  Inferencia local, nada sale de tu máquina          │
└─────────────────────────────────────────────────────┘

La clave es la división de roles: el modelo de embedding (chiquito y rápido) convierte tu código en vectores buscables, mientras el LLM (grande y potente) solo procesa el subset de código que es relevante para la pregunta actual.

Paso 1: Configurar Ollama

Ollama es el runtime que hace que la inferencia local de LLMs sea simple. Si todavía no lo tenés:

# macOS / Linux curl -fsSL https://ollama.com/install.sh | sh # Verificar la instalación ollama --version

Ahora descargá los modelos que necesitamos — uno para embeddings, otro para generación de código:

# Modelo de embedding (768 dimensiones, muy rápido) ollama pull nomic-embed-text # LLM enfocado en código — elegí según tu hardware: # 8GB RAM mínimo: ollama pull qwen3.5:8b # 16GB RAM recomendado: ollama pull qwen3.5:14b # 32GB+ RAM para la mejor calidad: ollama pull deepseek-coder-v2:33b

¿Por qué estos modelos?

nomic-embed-text es uno de los mejores modelos livianos de embedding local para código. Produce vectores de 768 dimensiones (ajustable hasta 64 vía Matryoshka Representation Learning), maneja bien la sintaxis de código y corre rápido incluso en CPU. Alternativas como bge-m3 (excelente para búsqueda híbrida) o snowflake-arctic-embed también funcionan bien — BGE-M3 en particular destaca en recuperación multilingüe y de contexto largo. Para la mayoría de setups locales, nomic ofrece la mejor relación velocidad-calidad en su tamaño compacto (~137M parámetros).

Qwen 3.5 (8B/14B) es el punto óptimo para generación local de código en 2026. Lanzado en febrero de 2026, supera a modelos anteriores en tamaños equivalentes en benchmarks de codificación (HumanEval+, MBPP+), soporta 262K tokens de contexto nativamente, incluye soporte multimodal nativo, y su "modo de pensamiento híbrido" con chain-of-thought mejora drásticamente la calidad del código. DeepSeek Coder V2 a 33B sigue siendo una alternativa sólida para calidad pura de generación de código si tenés VRAM suficiente.

Verificá que todo funcione:

# Test de embedding curl http://localhost:11434/api/embed -d '{ "model": "nomic-embed-text", "input": "function calculateTotal(items) { return items.reduce((sum, i) => sum + i.price, 0); }" }' # Test de generación ollama run qwen3.5:8b "Explicá qué es un pipeline RAG en 2 oraciones."

Paso 2: Chunking inteligente de la base de código

Acá es donde la mayoría de los tutoriales de RAG se rompen. Te dicen que dividas el texto en chunks fijos de 500 tokens. Para código, eso es un desastre. Una función partida en dos chunks no sirve para nada. Una clase sin métodos no tiene sentido.

Lo que necesitamos es chunking basado en AST — cortar el código en límites lógicos (funciones, clases, módulos) en vez de contar caracteres.

// 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(`Se dividieron ${files.length} archivos en ${chunks.length} chunks`); 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), }; }

Por qué importa el chunking basado en AST

Considerá este escenario real. Tenés una clase UserService:

export class UserService { async createUser(data: CreateUserDTO): Promise<User> { // ... 40 líneas de validación, hashing, inserción en DB } async getUserById(id: string): Promise<User | null> { // ... 15 líneas de recuperación con caché } async deleteUser(id: string): Promise<void> { // ... 25 líneas de lógica de eliminación en cascada } }

El chunking fijo de 500 tokens corta en medio de una función. El chunk 1 tiene la declaración de la clase y la mitad de createUser. El chunk 2 tiene la otra mitad de createUser y todo getUserById. Ningún chunk es útil por sí solo.

El chunking basado en AST produce tres chunks: uno por método, cada uno con el nombre de la clase y los imports del archivo como prefijo. Ahora cuando preguntás "¿cómo funciona la eliminación de usuarios?", el retriever encuentra el chunk de deleteUser con contexto completo.

Paso 3: Embedding y almacenamiento en ChromaDB

ChromaDB es la base de datos vectorial más fácil de correr localmente. Cero configuración, almacenamiento persistente y filtrado de metadatos integrado.

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('No inicializado'); 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(`Generando embeddings lote ${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} chunks embebidos en ChromaDB`); } async query( queryText: string, options: { nResults?: number; filterLanguage?: string; filterType?: string; } = {} ): Promise<QueryResult[]> { if (!this.collection) throw new Error('No inicializado'); 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; }

Indexar tu base de código

// src/index-codebase.ts import { chunkCodebase } from './chunker'; import { CodebaseEmbedder } from './embedder'; async function indexCodebase(targetDir: string) { const startTime = Date.now(); console.log(`Dividiendo la base de código en: ${targetDir}`); const chunks = await chunkCodebase(targetDir); console.log(`${chunks.length} chunks generados`); 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(`Indexación completa en ${elapsed}s`); const byLanguage = chunks.reduce((acc, c) => { acc[c.language] = (acc[c.language] || 0) + 1; return acc; }, {} as Record<string, number>); console.log('\nChunks por lenguaje:'); Object.entries(byLanguage) .sort(([, a], [, b]) => b - a) .forEach(([lang, count]) => console.log(` ${lang}: ${count}`)); } indexCodebase(process.argv[2] || '.');

Rendimiento típico de indexación en una Mac con chip M:

Tamaño del codebaseArchivosChunksTiempo
Pequeño (10K LOC)~50~200~30s
Mediano (50K LOC)~300~1,200~3min
Grande (200K LOC)~1,500~6,000~15min
Monorepo (1M LOC)~8,000~30,000~1hr

Paso 4: El pipeline RAG

Este es el ciclo principal: tomás la pregunta del desarrollador, encontrás los chunks de código relevantes y se los pasás al LLM como contexto.

// 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('Asistente de código listo. Conectado al índice de la base de código.'); } async ask(question: string): Promise<string> { // Paso 1: Recuperar chunks relevantes const relevantChunks = await this.embedder.query(question, { nResults: this.config.maxContextChunks, }); // Paso 2: Filtrar y ordenar por relevancia const filteredChunks = relevantChunks .filter(chunk => chunk.distance < 0.7) .slice(0, this.config.maxContextChunks); // Paso 3: Construir el prompt enriquecido const contextBlock = filteredChunks .map((chunk, i) => { const meta = chunk.metadata; return `--- Chunk ${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}`; // Paso 4: Generar respuesta via Ollama const response = await this.generate(systemPrompt, userPrompt); // Paso 5: Registrar la conversación 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 || 'No se pudo generar una respuesta.'; } clearHistory(): void { this.conversationHistory = []; } } // CLI interactivo 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🤖 Asistente de código local listo'); console.log('Preguntá lo que quieras sobre tu codebase. Escribí "exit" para salir.\n'); const askQuestion = () => { rl.question('Vos: ', async (input) => { const trimmed = input.trim(); if (trimmed.toLowerCase() === 'exit') { console.log('¡Hasta luego!'); rl.close(); return; } if (trimmed.toLowerCase() === 'clear') { assistant.clearHistory(); console.log('Conversación limpiada.\n'); askQuestion(); return; } try { const response = await assistant.ask(trimmed); console.log(`\nAsistente: ${response}\n`); } catch (error) { console.error('Error:', error); } askQuestion(); }); }; askQuestion(); } main().catch(console.error);

Paso 5: Optimizaciones avanzadas

El pipeline básico funciona, pero el uso en producción requiere varias optimizaciones.

5.1 Re-ranking para mayor precisión

La búsqueda por similitud vectorial devuelve resultados "semánticamente similares", pero similar no siempre significa relevante. Un paso de re-ranking usa el LLM para filtrar falsos positivos:

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 Indexación incremental

Re-indexar toda la base de código en cada cambio es un desperdicio. Usá los tiempos de modificación de archivos para embeber solo los que cambiaron:

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 Recuperación multi-consulta

A veces una sola búsqueda de embeddings no encuentra todo el contexto relevante. Generá múltiples queries de búsqueda a partir de la pregunta original:

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 Observar cambios en archivos

Para una experiencia fluida, observá el sistema de archivos y re-indexá automáticamente:

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('Archivos modificados, re-indexando...'); const stats = await incrementalIndex(rootDir, embedder, '.index-manifest.json'); console.log(`Re-indexación completa: +${stats.added} ~${stats.updated} -${stats.removed}`); }, 2000); }; watcher.on('change', scheduleReindex); watcher.on('add', scheduleReindex); watcher.on('unlink', scheduleReindex); console.log(`Observando ${rootDir} para detectar cambios...`); }

Benchmarks de rendimiento

Esto es lo que podés esperar en hardware real (testeado en abril de 2026):

Velocidad de indexación (nomic-embed-text)

Hardware1K Chunks5K Chunks10K Chunks
M3 MacBook Pro (36GB)18s85s170s
M2 MacBook Air (16GB)32s155s310s
RTX 4090 (24GB VRAM)8s38s75s
Solo CPU (AMD 7950X)45s220s440s

Latencia de generación (tiempo al primer token)

ModeloM3 Pro (36GB)RTX 4090CPU (7950X)
Qwen 3.5 8B0.8s0.3s3.2s
Qwen 3.5 14B1.5s0.5s8.1s
DeepSeek Coder V2 33B3.2s0.9sN/A (OOM)

Calidad de recuperación (en un monorepo TypeScript de 50K LOC)

MétricaChunking fijoChunking por AST
Relevancia Top-142%71%
Recall Top-561%89%
Con Re-ranking68%94%

La combinación de chunking por AST + re-ranking prácticamente duplica la precisión comparada con los enfoques ingenuos.

Errores comunes y cómo evitarlos

Error 1: Modelo de embedding inconsistente

Si embebés con nomic-embed-text pero hacés las consultas con mxbai-embed-large, los resultados van a ser basura. El modelo de embedding debe ser idéntico para indexación y consultas. Suena obvio, pero es el error #1 cuando cambiás de modelo durante el desarrollo.

Error 2: Extremos en el tamaño de chunks

Chunks demasiado pequeños (líneas individuales) pierden contexto. Chunks demasiado grandes (archivos enteros) diluyen la señal semántica. El punto justo para código es 50–300 líneas por chunk, que corresponde a funciones individuales o clases pequeñas.

Error 3: Ignorar el filtrado de metadatos

Sin filtrado de metadatos, una consulta sobre código de autenticación en Python podría devolver utilidades de testing en TypeScript que casualmente mencionan "auth". Siempre almacená y usá metadatos (lenguaje, tipo de archivo, nombre de módulo) para acotar la búsqueda.

Error 4: Índice desactualizado

Tu codebase cambia todos los días, pero tu índice no se actualiza. Configurá la indexación incremental (Sección 5.2) o, como mínimo, re-indexá en cada git pull con un hook post-merge:

# .git/hooks/post-merge #!/bin/sh npx tsx src/index-codebase.ts . & echo "Re-indexando la base de código en segundo plano..."

Error 5: Overflow de la ventana de contexto

Incluso con RAG, podés reventar la ventana de contexto recuperando demasiados chunks. Un modelo de 14B con una ventana de 32K tokens puede manejar cómodamente ~20K tokens de contexto de código + 4K para el system prompt + 4K para el historial + 4K para la respuesta. Eso equivale a unos 8–10 chunks de código. Pasarse de ahí degrada la calidad.

Cuándo usar esto vs. IA en la nube

Este enfoque local no siempre es mejor que las APIs en la nube. Acá va la comparación honesta:

FactorLocal (Ollama + RAG)Nube (GPT-4.1 / Claude Opus 4.6)
Privacidad✅ Nada sale de tu máquina❌ Código enviado a servidores externos
Costo✅ Gratis después del hardware❌ $2–15/M tokens
Calidad del código⚠️ Buena (14B) a Muy buena (33B+)✅ La mejor disponible
Configuración❌ ~2 horas de setup inicial✅ API key y listo
Latencia⚠️ 1–3s en buen hardware✅ <1s (streaming)
Comprensión del código✅ Profunda (RAG en todo el repo)⚠️ Limitada a la ventana de contexto
Offline✅ Funciona sin internet❌ Requiere internet

Usá local cuando: tu codebase es propietario, trabajás en industrias reguladas (salud, finanzas, defensa), tenés un monorepo grande, o querés costo recurrente cero.

Usá la nube cuando: la calidad del código es lo más importante (GPT-4.1 y Claude Opus 4.6 todavía superan a cualquier modelo local de 14B), el tiempo de setup importa, o tu equipo ya tiene presupuesto para APIs.

El enfoque híbrido: Usá RAG local para la recuperación de código, pero enviá la generación final a una API en la nube para máxima calidad. Tu contexto de código se queda local; solo el prompt armado (con los snippets relevantes) va a la nube.

Próximos pasos

Esta guía te da una base sólida. Para ir más lejos:

  1. Agregá parsing con tree-sitter para chunking preciso por AST en todos los lenguajes, reemplazando el enfoque basado en regex.
  2. Integrá con tu editor — construí una extensión de VS Code o plugin de Neovim que consulte al asistente inline.
  3. Agregá contexto de git — incluí diffs recientes, información de blame y descripciones de PR en los metadatos de recuperación.
  4. Implementá loops agénticos — dejá que el asistente ejecute búsquedas, lea archivos y corra tests autónomamente usando function calling.
  5. Afiná el modelo de embedding con tu codebase usando contrastive learning para mejorar la precisión de recuperación en un 15–20%.

Las herramientas están acá. Los modelos son lo suficientemente buenos. La infraestructura corre en una sola laptop. Lo único que te separa de un asistente de código privado y consciente del contexto es un fin de semana de configuración.

AIOllamaRAGLLMIA localasistente de códigoChromaDBembeddingsprivacidadherramientas de desarrollo

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit