Leo-code tiene tres componentes que hacen el trabajo. Aquí está cómo funcionan juntos.
1. Parser: AST sin magia
Punto de inicio: código fuente. Necesito extraer funciones, clases, módulos con sus límites exactos.
Podría usar un LLM. Sería lento y caro. En su lugar, uso tree-sitter: parsea Python (y otros lenguajes) como árbol sintáctico puro. No lo interpreta. Solo lo estructura.
En milisegundos, tengo:
- Cada función: nombre, parámetros (con tipos), línea inicial, línea final
- Cada clase: nombre, métodos, propiedades, herencia
- Cada módulo: imports, exports, variables globales
- Cada import: qué se importa, desde dónde
El parser es determinista. Mismo código = mismo árbol. No hay variabilidad. No hay LLM involved.
2. Cápsula: unidad atómica
Estructuro cada función/clase/módulo como cápsula:
@dataclass
class Capsule:
id: str # SHA256(path + línea + firma)
type: str # function, class, module, document
name: str # nombre de la función
file_path: str # ruta relativa
start_line: int
end_line: int
signature: str # def foo(a: int) -> bool
content: str # cuerpo entero, sin cortes
docstring: str # si existe
calls: list[str] # funciones que llama
called_by: list[str] # quién la llama (resuelto por grafo)
imports: list[str] # dependencias
properties: dict # metadata adicional
Cada cápsula es autocontainida. Tiene todo lo que necesita para que el LLM entienda qué hace, quién la usa, y qué depende de ella.
El ID es hash del contenido + ubicación. Cambias la función, cambia el ID. Esto permite detectar si el índice está desactualizado.
3. Grafo de dependencias: quién llama a quién
Las cápsulas se conectan. Función A llama a función B. Clase X hereda de clase Y.
Construyo grafo BFS (breadth-first search) de dependencias:
- Para cada cápsula, resuelvo sus
calls: ¿qué otras cápsulas llama? - Para cada cápsula, resuelvo
called_by: ¿quién la llama?
Esto es importante para compresión. Si el LLM necesita refactorizar una función, no solo le doy la función. Le doy la función + TODAS sus dependencias (todo lo que llama) + TODOS sus usuarios (todo lo que la llama). Contexto completo.
4. Indexación: cápsulas → Qdrant
Una vez tengo cápsulas, las indexo en Qdrant.
Para cada cápsula:
- Genera embedding (all-MiniLM-L6-v2, 384 dims) del content + docstring
- Almacena cápsula como punto en Qdrant con metadatos (type, name, file_path, etc.)
Qdrant usa HNSW (Hierarchical Navigable Small World): búsqueda vectorial muy rápida (~50ms para 10k cápsulas).
Los embeddings corren localmente (no pago a OpenAI por embeddings). Costo: negligible.
5. Búsqueda: hybrid (exact + semantic)
Cuando pregunto algo, el pipeline es:
- Classify: detecta tipo de query (code_query, refactor, code_gen, etc.) usando keywords
- Exact match: busca en metadatos (nombre de función, palabras clave)
- Semantic: embeddings en Qdrant
- Merge: exact match primero, luego semántica
- Top-K: toma top 15 resultados
6. Compresión: adaptativa según tarea
Top 15 cápsulas devueltas = demasiadas para el LLM. Necesito comprimir.
Pero la compresión es inteligente. Según el tipo de tarea, estrategia diferente:
code_query ("¿qué hace X?"): Primera cápsula con cuerpo entero, resto con firma + docstring + relaciones. Resultado: 500-2000 tokens. LLM ve respuesta a pregunta + contexto de relaciones.
refactor ("refactoriza función X"): Función target + todas sus callees (lo que llama) + todos sus callers (quién la llama). Resultado: 800-1500 tokens. LLM ve función + el ecosistema donde vive.
code_gen ("genera función para hacer X"): Estructura de directorios + ejemplos de funciones parecidas, sin cuerpos completos. Resultado: 200-600 tokens. LLM entiende dónde encaja el código nuevo.
search ("encuentra funciones sobre X"): Mini-mapa de funciones por archivo, con indicador de documentación. Resultado: 300-800 tokens. LLM recibe index, no código.
La compresión es agnóstica: mismos datos, diferentes vistas según necesidad.
7. Caché + invalidación
Resultados de /context se cachean en Redis por 60s. Razón: si la pregunta es igual, la respuesta es igual. No reparseo.
Cuando se reindexan cápsulas (código cambió), caché se invalida. Siguiente query usa índice nuevo.
El sistema completo
Código fuente ↓ Tree-sitter AST Cápsulas (funciones/clases/módulos completos) ↓ BFS grafo (calls/called_by) Cápsulas + relaciones ↓ all-MiniLM embeddings Cápsulas indexadas en Qdrant ↓ Query (exact + semantic search) Top 15 relevantes ↓ Clasificador de tarea Tipo de pregunta detected ↓ Compresor adaptativo Contexto comprimido (~400-2000 tokens) ↓ Se inyecta en system prompt LLM responde sin abrir archivos
Por qué funciona
No hay magia. Cada paso es simple:
- Parser: análisis estático (determinista)
- Grafo: BFS sobre relaciones (algor trivial)
- Búsqueda: hybrid de dos técnicas probadas
- Compresión: regex + tree filtering (nada de LLM)
Lo que hace funcionar el sistema es la combinación. Cada parte cubre debilidades de la anterior. Exact match solo → falla con nombres diferentes. Embeddings solo → falla con falsos positivos. Juntos → funcionan.
Misma lógica con cápsulas: archivos completos son demasiados datos. Fragmentos de 500 tokens cortan funciones. Cápsulas (funciones completas) → punto exacto de granularidad.
Arquitectura simple. Decisiones conscientes. Resultado: agente que busca código de forma eficiente sin alucinar.
