Saltar al contenido
Blog
2026-06-037 min lectura

Arquitectura: cómo AST, embeddings y grafos trabajan juntos.

Parser determinista. Cápsulas como unidad atómica. Compresión adaptativa según tarea. El sistema completo.

leo-codeArquitecturaParserGrafos
Arquitectura: cómo AST, embeddings y grafos trabajan juntos.

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:

  1. Genera embedding (all-MiniLM-L6-v2, 384 dims) del content + docstring
  2. 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:

  1. Classify: detecta tipo de query (code_query, refactor, code_gen, etc.) usando keywords
  2. Exact match: busca en metadatos (nombre de función, palabras clave)
  3. Semantic: embeddings en Qdrant
  4. Merge: exact match primero, luego semántica
  5. 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.

Escrito porIsmael Manzano LeónDesarrollador de Software · leo/ · leosoftware.dev
Hablar con Ismael →