← Todos los articulos

Construcción de un recuperador híbrido para 16.894 archivos de Obsidian

From the guide: Claude Code Comprehensive Guide

Un grep a través de 16.894 archivos markdown tarda entre 11 y 66 segundos según el término y devuelve cientos de coincidencias de baja relevancia. Una búsqueda vectorial devuelve contenido semánticamente relacionado pero no encuentra el nombre exacto de la función que escribió. Un recuperador híbrido que fusiona ambos métodos devuelve la respuesta correcta en 23 milisegundos (de extremo a extremo, incluyendo el embedding de la consulta) desde un único archivo SQLite de 83 MB sin ninguna llamada a API.1

El problema del tomador de notas obsesivo no es la recolección. El problema es la recuperación. Obsidian hace que capturar información sea algo sin fricción. Acumule suficientes archivos y la bóveda se convierte en una base de datos de solo escritura: fácil de agregar, imposible de consultar. Buscar por nombre de archivo funciona hasta que los nombres pierden significado. La búsqueda de texto completo funciona hasta que la misma palabra clave aparece en 400 documentos. Las etiquetas funcionan hasta que olvida etiquetar algo.

Un comentarista de HN pidió la arquitectura completa detrás del sistema de recuperación que construí para mi bóveda de Obsidian.2 Aquí está: la estrategia de fragmentación, el modelo de embeddings, el esquema SQLite de doble índice, las matemáticas de fusión con números reales y los modos de fallo que encontré después de consultar el sistema cientos de veces.

Resumen

El recuperador combina la búsqueda por palabras clave FTS5 BM25 con la búsqueda por similitud vectorial Model2Vec, fusionadas mediante Reciprocal Rank Fusion (RRF) en una lista única ordenada. Todo se ejecuta localmente en una sola base de datos SQLite: 49.746 fragmentos de 16.894 archivos en 83 MB. La reindexación completa tarda cuatro minutos. Las actualizaciones incrementales se ejecutan en menos de diez segundos. El sistema se integra con Claude Code a través de hooks, dando al agente acceso al conocimiento de la bóveda sin cargar archivos en el contexto. BM25 captura identificadores exactos y nombres de funciones. La búsqueda vectorial captura coincidencias semánticas a través de terminología diferente. RRF combina ambos sin requerir calibración de puntuaciones. La compensación honesta: el contenido superficial bien etiquetado puede superar en ranking al contenido profundo mal estructurado porque BM25 recompensa la densidad de palabras clave, no la profundidad.


Puntos clave

Para tomadores de notas con bóvedas grandes. En mi experiencia, la búsqueda de texto completo por sí sola se volvió inutilizable a partir de unos pocos miles de archivos — y los plugins de búsqueda existentes de Obsidian (Smart Connections, Omnisearch) indexan dentro de la aplicación, no como una biblioteca externa que otras herramientas puedan consultar.1 Agregar búsqueda vectorial sobre BM25 captura las consultas donde recuerda el concepto pero no la palabra clave. El recuperador se ejecuta enteramente sobre SQLite sin servicios externos, sin GPU y sin costos de API. Model2Vec genera embeddings a velocidad de CPU porque el modelo es de 30 MB de vectores de palabras estáticos, no un transformer.3

Para desarrolladores que construyen sistemas de recuperación. RRF es el método de fusión que requiere menos ajuste. La fórmula utiliza solo posiciones de ranking, no puntuaciones brutas, por lo que nunca necesita calibrar puntuaciones BM25 contra distancias coseno. Comience con k=60 y pesos iguales. Ajuste solo después de medir casos de fallo en sus propios datos. La extensión sqlite-vec incorpora la búsqueda vectorial KNN en SQLite sin una base de datos vectorial separada.4

Para usuarios de Claude Code. El recuperador funciona como una biblioteca que los hooks pueden invocar. Un hook PreToolUse consulta la bóveda antes de que el agente comience a trabajar. El agente ve 2-3 KB de resultados enfocados con atribución de ruta de archivo en lugar de cargar archivos completos. La integración mantiene las ventanas de contexto pequeñas mientras da al agente acceso a 16.894 archivos de conocimiento.

Versión mínima viable. El punto de partida más simple: cree una tabla virtual FTS5 sobre sus archivos markdown (solo BM25, sin embeddings). Agregue sqlite-vec y Model2Vec cuando la búsqueda por palabras clave comience a perder coincidencias semánticas. Agregue la fusión RRF al final. Cada capa funciona de forma independiente. El stack completo requiere Python 3, una descarga de modelo de 30 MB y pip install model2vec sqlite-vec. Sin GPU, sin Docker, sin servicios externos. Huella total en disco para 16.894 archivos: 83 MB.

¿Desea la guía operativa completa? La referencia de Infraestructura de IA para Obsidian cubre la arquitectura de la bóveda, la configuración de plugins, la configuración del servidor MCP, recetas de indexación incremental y solución de problemas — el complemento paso a paso de la inmersión arquitectónica en este artículo.


Por qué la búsqueda por palabras clave falla a escala

La búsqueda de texto completo se descompone a escala de bóveda de maneras predecibles. FTS5 con ranking BM25 sobresale en coincidencias exactas: busque requestAnimationFrame y cada archivo que contiene ese token exacto aparece, ordenado por frecuencia de término y longitud del documento.5 La revisión de Robertson y Zaragoza sobre modelos de relevancia probabilística confirma la fortaleza de BM25: el algoritmo funciona bien en consultas con muchas palabras clave con un ajuste mínimo de parámetros.14 El modo de fallo son los sinónimos y la coincidencia de conceptos. Busque “how to handle authentication failures” y BM25 devuelve cada archivo que menciona “authentication” o “failures” individualmente, diluyendo los resultados con contenido tangencialmente relacionado.

La búsqueda vectorial resuelve el problema de los sinónimos. Genere el embedding de la consulta y encuentre los fragmentos cuyos embeddings se ubican cerca en el espacio vectorial. “How to handle authentication failures” coincide con contenido sobre “login error recovery” y “session expiration handling” porque el embedding captura la similitud semántica a través de terminología diferente.6 Karpukhin et al. demostraron con Dense Passage Retrieval (DPR) que los embeddings densos superan a BM25 en respuesta a preguntas de dominio abierto en un 9-19% en precisión top-20, precisamente porque las representaciones densas capturan significado más allá de la superposición léxica.15 El modo de fallo es el opuesto: la búsqueda vectorial pierde los identificadores exactos. Busque el nombre de función _rrf_fuse y la búsqueda vectorial devuelve contenido sobre algoritmos de fusión y ranking, pero puede clasificar la definición real de la función por debajo de una explicación conceptual.

Ninguno de los dos métodos por sí solo cubre ambos modos de fallo. Una sola consulta ilustra la diferencia (no es prueba de superioridad — la evaluación agregada requiere un conjunto dorado, que el sistema aún no tiene). La consulta “PostToolUse hook for context compression” devuelve diferentes resultados top-3 de cada método:

Ranking Solo BM25 Solo vectorial Híbrido (RRF)
1 hook-stdlib.sh “PostToolUse Handler” context-is-the-new-memory.md “Compression Layers” context-is-the-new-memory.md “Compression Layers”
2 settings.json “PostToolUse Events” token-budget-analysis.md “Context Engineering” hook-stdlib.sh “PostToolUse Handler”
3 compress-output.sh “Tool Output Filter” agent-memory-patterns.md “Retrieval Integration” compress-output.sh “Tool Output Filter”

BM25 encontró el archivo exacto del hook y la referencia de configuración (coincidencia de palabras clave en “PostToolUse”) pero no encontró la nota conceptual sobre ingeniería de contexto. La búsqueda vectorial encontró las notas de estrategia de compresión (coincidencia semántica en “context compression”) pero no encontró la implementación específica del hook. RRF promovió las notas que importan tanto para el concepto como para la implementación, colocando la nota de estrategia y el archivo del hook en las posiciones uno y dos.13

La investigación sobre ranking de pasajes de MS MARCO apoya el patrón en benchmarks de búsqueda web: la recuperación híbrida supera consistentemente a BM25 o la recuperación densa por separado, con las mayores ganancias en consultas que contienen tanto términos específicos como conceptos abstractos.716


La arquitectura: tres capas que se potencian

El sistema tiene tres capas independientes. Cada una funciona sin las otras, pero juntas se potencian mutuamente.

Capa 1: Ingesta. Un pipeline de puntuación de Python de 733 líneas califica cada señal entrante en cuatro dimensiones: relevancia, accionabilidad, profundidad y autoridad. Las señales con puntuación de 0,55 o superior se enrutan automáticamente a una de 12 carpetas de dominio. Las señales entre 0,40 y 0,55 se ponen en cola para revisión manual. Por debajo de 0,40, el pipeline descarta la señal. El pipeline ha procesado 7.771 señales a lo largo de 14 meses sin etiquetado manual.1 La capa de ingesta determina qué entra a la bóveda. La capa de recuperación lo hace encontrable.

Capa 2: Recuperación. El motor de búsqueda híbrida cubierto en detalle a continuación. El motor fragmenta cada archivo en los límites de encabezados, genera embeddings de los fragmentos con Model2Vec y los indexa en SQLite con una tabla vec0 para KNN vectorial y una tabla virtual FTS5 para BM25. Una consulta se ejecuta contra ambos índices simultáneamente, y RRF fusiona los resultados en una lista única ordenada.

Capa 3: Integración. Hooks de Claude Code que conectan el recuperador al flujo de trabajo del agente. Un hook se dispara al enviar un prompt, consulta la bóveda buscando contexto relevante e inyecta los resultados principales en la conversación. El agente ve fragmentos enfocados con atribución de fuente en lugar de contenido de archivos sin procesar:

# Illustrative output (format matches production, content simplified)
## Relevant Memory Context

### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...

### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...

Cada resultado lleva el encabezado de sección y el proyecto de origen, limitado a un presupuesto de 500 tokens para evitar la saturación del contexto.

El recuperador también habilita un segundo punto de integración: un hook PostToolUse que comprime las salidas de las herramientas antes de que entren a la conversación. La salida bruta de las herramientas contiene marcas de tiempo, artefactos de ordenamiento y formato verboso que varían entre ejecuciones. El recuperador reemplaza el volcado bruto con un subconjunto estable y enfocado. El agente nunca ve el ruido, solo el extracto relevante. Un beneficio secundario: debido a que la salida del recuperador es determinista para la misma consulta (el mismo estado del índice produce los mismos resultados ordenados), la salida comprimida ayuda al almacenamiento en caché de prompts. Las consultas repetidas contra datos sin cambios producen bloques de contexto idénticos, y el almacenamiento automático en caché de prompts de CLI reutiliza el prefijo almacenado.

La historia más amplia de la infraestructura explica cómo los hooks, skills y agentes se componen en una capa programable alrededor del modelo.

Las capas están desacopladas por diseño. La puntuación de ingesta no sabe nada sobre embeddings. El recuperador no sabe nada sobre las reglas de enrutamiento de señales. Pero la ingesta asegura que la bóveda contenga contenido de alta calidad, la recuperación presenta el subconjunto correcto para cualquier consulta y la integración entrega ese subconjunto al agente sin saturar el contexto. Escribí sobre el marco teórico del contexto como el recurso crítico. El recuperador es la implementación práctica.


Fragmentación: donde comienza la calidad de la recuperación

La fragmentación determina la granularidad de los resultados de búsqueda. Si los fragmentos son demasiado grandes, la búsqueda vectorial devuelve archivos completos donde solo un párrafo es relevante. Si son demasiado pequeños, el embedding pierde el contexto necesario para la coincidencia semántica. La investigación sobre pipelines de RAG confirma que el tamaño del fragmento tiene un impacto mayor en la calidad de la recuperación que la elección del modelo para la mayoría de los casos de uso, con fragmentos de 200-500 tokens ofreciendo el mejor rendimiento para tareas de recuperación a nivel de párrafo.18

El fragmentador divide en los límites de encabezados H2 (##), preservando la estructura markdown.8 Una nota sobre la rotación de tokens OAuth con tres secciones H2 se convierte en tres fragmentos, cada uno lo suficientemente autocontenido para que el embedding capture su significado. El indexador almacena el texto del encabezado y el título de la nota padre como metadatos junto a cada fragmento, proporcionando contexto para la coincidencia BM25 incluso cuando el texto del fragmento en sí es escaso.

# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000

def _split_at_headings(body):
    sections = []
    current_heading = ""
    current_lines = []
    for line in body.split("\n"):
        if line.startswith("## "):
            if current_lines:
                text = "\n".join(current_lines).strip()
                if text:
                    sections.append((current_heading, text))
            current_heading = line[3:].strip()
            current_lines = []
        else:
            current_lines.append(line)
    if current_lines:
        text = "\n".join(current_lines).strip()
        if text:
            sections.append((current_heading, text))
    return sections

El fragmentador divide adicionalmente las secciones que exceden los 2.000 caracteres: primero en los límites H3, luego en los saltos de párrafo. Descarta las secciones de menos de 30 caracteres. El fragmentador también omite las secciones Related, See Also, Links y References, que típicamente son listas de wiki-links en lugar de contenido buscable.

Dos decisiones de diseño importan para la calidad de la recuperación. Primero, el indexador almacena la cadena de contexto del encabezado ("OAuth Token Rotation | note | security, authentication") en una columna separada y la indexa en FTS5 con un peso menor (0,3) que el texto del fragmento (1,0). BM25 aún coincide en el encabezado cuando el cuerpo del fragmento no contiene el término de búsqueda, pero la coincidencia de encabezado puntúa más bajo que una coincidencia en el cuerpo. Segundo, el fragmentador extrae las etiquetas del frontmatter y el tipo de nota y los incluye en el contexto del encabezado, de modo que una búsqueda de “security” coincide con notas etiquetadas con security incluso cuando el texto del cuerpo usa terminología diferente.


Embeddings: modelo de 30 MB, cero llamadas a API

El modelo de embeddings es potion-base-8M de Model2Vec, un modelo de embeddings de palabras estáticos con 7,6 millones de parámetros que produce vectores de 256 dimensiones.3 En el conjunto de benchmarks MTEB, potion-base-8M alcanza el 89% del rendimiento de all-MiniLM-L6-v2 (50,03 vs 56,09 promedio) a hasta 500 veces la velocidad de inferencia, haciéndolo práctico para indexar grandes corpus en hardware de consumo.917 Una advertencia: la sub-puntuación de Retrieval de MTEB del modelo es notablemente más baja (31,71) que sus puntuaciones de Classification (64,44) o STS (73,24). Los benchmarks de recuperación de MTEB prueban el ranking a nivel de documento en corpus web, no la coincidencia a nivel de párrafo en fragmentos markdown homogéneos. La brecha importa menos cuando los fragmentos son cortos, temáticamente enfocados y escritos con un vocabulario consistente. A diferencia de los modelos de embeddings basados en transformers, Model2Vec no ejecuta capas de atención sobre la entrada. El modelo destila el conocimiento de un sentence transformer en embeddings de tokens estáticos, produciendo vectores mediante promedio ponderado en lugar de computación secuencial.9

¿Por qué funcionan los embeddings estáticos para este caso de uso? Los fragmentos cortos de markdown (200-400 palabras en promedio) contienen vocabulario concentrado sobre un solo tema. El promedio ponderado de esos vectores de tokens aterriza en una región significativa del espacio de embeddings porque hay poca dilución fuera de tema. En la práctica, un documento de 2.000 palabras que cubre tres temas diferentes tiende a producir un centroide difuso que se ubica entre clusters temáticos en lugar de dentro de uno. Un fragmento sobre rotación de tokens OAuth, por el contrario, produce un vector que se agrupa estrechamente con otro contenido de autenticación. Los embeddings estáticos intercambian la desambiguación contextual (la palabra “bank” en “river bank” vs “bank account”) por velocidad bruta. En una base de conocimiento personal donde cada fragmento cubre un concepto, la penalización por ambigüedad es pequeña y el paper reporta hasta 500 veces de aceleración en inferencia.9

# embedder.py: lazy-loading Model2Vec in a dedicated venv
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256

class Model2VecEmbedder:
    def __init__(self, model_name=DEFAULT_MODEL):
        self._model_name = model_name
        self._model = None

    def _ensure_model(self):
        if self._model is not None:
            return
        _activate_venv()  # Add memory venv to sys.path
        from model2vec import StaticModel
        self._model = StaticModel.from_pretrained(self._model_name)

    def embed_batch(self, texts):
        self._ensure_model()
        vecs = self._model.encode(texts)
        return [v.tolist() for v in vecs]

La consecuencia práctica: una reindexación completa de 16.894 archivos se completa en cuatro minutos en un Apple M3 Pro. La indexación incremental (solo archivos modificados, detectados por comparación de mtime) se ejecuta en menos de diez segundos en las ediciones de un día típico.1

El modelo se ejecuta en un entorno virtual aislado en ~/.claude/venvs/memory/ para evitar conflictos de dependencias con el resto de la cadena de herramientas. El embedder carga el modelo de forma perezosa en el primer uso, no al importar, de modo que importar el módulo no cuesta nada cuando el recuperador recurre al modo solo BM25.

¿Por qué no un modelo más grande? Dos razones. Primero, los vectores de 256 dimensiones mantienen la base de datos SQLite en 83 MB para 49.746 fragmentos. Vectores de mayor dimensionalidad (768 o 1.024) triplicarían o cuadruplicarían el tamaño de la base de datos con una mejora marginal de calidad en fragmentos cortos de markdown.10 Segundo, los embeddings basados en API (text-embedding-3-small de OpenAI a $0,02 por millón de tokens, por ejemplo) introducen latencia, costo y una dependencia de red para un sistema que debería funcionar sin conexión.11 La re-generación completa de embeddings de la bóveda cuesta aproximadamente $0,30 a precios de API, trivial de forma aislada, pero el costo real es la latencia de ida y vuelta multiplicada por 49.746 fragmentos y la implicación de privacidad de enviar notas personales a una API externa.

Un mecanismo de hash del modelo rastrea la compatibilidad de los embeddings. El indexador almacena un hash derivado del nombre del modelo y el tamaño del vocabulario. Si el modelo cambia, la indexación incremental detecta la incompatibilidad y dispara una reindexación completa automáticamente.


El esquema SQLite: tres tablas, un archivo

Todo el índice vive en un solo archivo SQLite (vectors.db, 83 MB) usando el modo WAL para seguridad de lectura concurrente.12 Tres tablas sirven diferentes propósitos:

-- Chunk content and metadata
CREATE TABLE chunks (
    id INTEGER PRIMARY KEY,
    file_path TEXT NOT NULL,
    section TEXT NOT NULL,
    chunk_text TEXT NOT NULL,
    heading_context TEXT DEFAULT '',
    mtime_ns INTEGER NOT NULL,
    embedded_at REAL NOT NULL
);

-- FTS5 for BM25 search (content-synced to chunks)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
    chunk_text, section, heading_context,
    content=chunks, content_rowid=id
);

-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
    id INTEGER PRIMARY KEY,
    embedding float[256]
);

La tabla FTS5 usa un patrón de sincronización de contenido: referencia la tabla chunks directamente en lugar de almacenar una copia duplicada del texto.5 Un detalle importante: las tablas con sincronización de contenido no propagan las eliminaciones automáticamente. El indexador debe emitir comandos explícitos INSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?) antes de eliminar filas de la tabla chunks, o el índice FTS5 se vuelve silenciosamente inconsistente. Los pesos de columna en las consultas BM25 asignan 1,0 al texto del fragmento, 0,5 a los encabezados de sección y 0,3 al contexto del encabezado:

# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score

La extensión sqlite-vec almacena vectores de punto flotante de 256 dimensiones como datos binarios empaquetados y soporta consultas KNN con distancia coseno.4 struct.pack de Python serializa los vectores:

def _serialize_vector(vec):
    return struct.pack(f"{len(vec)}f", *vec)

El esquema maneja la degradación gradual por diseño. Si sqlite-vec falla al cargar (extensión faltante, plataforma incompatible), el recuperador recurre a la búsqueda solo BM25. La propiedad vec_available rastrea si la búsqueda vectorial está operativa.


Reciprocal Rank Fusion: las matemáticas que lo hacen funcionar

RRF fusiona dos listas ordenadas sin requerir calibración de puntuaciones.7 ¿Por qué no combinar las puntuaciones brutas directamente? BM25 devuelve puntuaciones de relevancia negativas (más negativo = más relevante en la implementación FTS5 de SQLite) mientras que la distancia coseno devuelve valores entre 0 y 2. Comparar estas escalas requiere normalización que es sensible a la distribución de consultas. RRF evita el problema por completo al usar solo posiciones de ranking, no puntuaciones. La fórmula asigna a cada documento una puntuación basada en dónde apareció en cada lista:

score(d) = Σ (weight_i / (k + rank_i))

Donde k es una constante (60 en la implementación, siguiendo el paper original de Cormack et al.7), rank_i es el ranking del documento en la lista de resultados i, y weight_i es un multiplicador opcional por lista (por defecto 1,0 para ambas).

A continuación un ejemplo resuelto con rankings reales. Considere una consulta: “how does the review aggregator handle disagreements.” Cinco fragmentos aparecen en los resultados combinados:

Fragmento Ranking BM25 Ranking Vec RRF BM25 RRF Vec Puntuación fusionada
review-aggregator.py “Disagreement Resolution” 3 1 1/63 = 0,0159 1/61 = 0,0164 0,0323
deliberation-config.json “Review Weights” 1 8 1/61 = 0,0164 1/68 = 0,0147 0,0311
code-review MOC “Multi-Agent Review” 7 2 1/67 = 0,0149 1/62 = 0,0161 0,0310
jiro-artisan.sh “Review State Machine” 2 12 1/62 = 0,0161 1/72 = 0,0139 0,0300
quality-loop.md “Evidence Gate” - 3 0 1/63 = 0,0159 0,0159

El primer fragmento gana porque se posiciona bien en ambas listas. BM25 coincidió con “review,” “aggregator” y “disagreements” en el texto. La búsqueda vectorial coincidió con el concepto semántico de resolución de conflictos en revisión de código. El segundo fragmento se clasificó primero en BM25 (coincidencia exacta de palabras clave en “review” en el archivo de configuración) pero octavo en búsqueda vectorial (el JSON de configuración es semánticamente escaso). RRF lo bajó apropiadamente. El último fragmento apareció solo en los resultados vectoriales, por lo que recibió una puntuación RRF de una sola fuente.

# retriever.py: RRF fusion core
RRF_K = 60

def _rrf_fuse(self, bm25_results, vec_results,
              bm25_weight=1.0, vec_weight=1.0):
    scores = {}
    for rank, r in enumerate(bm25_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {"rrf_score": 0.0, ...}
        scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
        scores[cid]["bm25_rank"] = rank

    for rank, r in enumerate(vec_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {"rrf_score": 0.0, ...}
        scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
        scores[cid]["vec_rank"] = rank

    return [SearchResult(chunk_id=cid, **data)
            for cid, data in scores.items()]

El grupo de candidatos por defecto es de 30 resultados de cada fuente antes de la fusión, produciendo hasta 60 candidatos. El recuperador devuelve los 10 mejores resultados fusionados. Un parámetro opcional max_tokens trunca los resultados para ajustarse a un presupuesto de tokens, estimando 4 caracteres por token.


Indexación: completa e incremental

El indexador soporta dos modos. La reindexación completa limpia la base de datos y reconstruye desde cero. La indexación incremental compara los tiempos de modificación de archivos (mtime_ns) contra las marcas de tiempo almacenadas y solo reprocesa los archivos modificados.1

# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes)  # mtime changed or new
deleted = index.get_deleted_files(vault_paths)  # no longer in vault

La generación de embeddings se ejecuta en lotes de 64 textos para amortizar la sobrecarga de Model2Vec.8 Un contador de progreso imprime cada 500 archivos durante la reindexación completa. Un manejador de SIGINT habilita el apagado gradual, terminando el archivo actual antes de detenerse.

El archivo de configuración usa un modelo de lista blanca para controlar la indexación de carpetas. La bóveda tiene 22 carpetas permitidas y 5 carpetas permanentemente excluidas (notas personales de salud, documentos de carrera, directorios internos de Obsidian).20 El indexador procesa solo archivos dentro de las carpetas permitidas y omite todo lo demás.

Una decisión de diseño crítica: el indexador ejecuta un filtro de credenciales en cada fragmento antes de almacenarlo. Las notas personales contienen claves de API, tokens bearer, cadenas de conexión a bases de datos y claves privadas pegadas durante sesiones de depuración. El filtro de credenciales coincide con 21 patrones específicos de proveedores (claves de OpenAI, PATs de GitHub, claves de acceso de AWS, tokens de Stripe y 17 otros) más 11 detectores genéricos para URLs de bases de datos, JWTs, tokens bearer, asignaciones de contraseñas y cadenas base64 de alta entropía.20 El filtro reemplaza el contenido coincidente con tokens [REDACTED:pattern-name] y registra qué patrones se activaron pero nunca registra el secreto en sí.

# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
    logger.info("Scrubbed %d credential(s) from %s [%s]",
                scan_result.match_count, file_path, sub_heading)

Indexar notas personales sin filtrado de credenciales crearía una base de datos buscable de secretos. El filtro se ejecuta antes de la generación de embeddings, de modo que las representaciones vectoriales nunca codifican patrones de credenciales. Una consulta para “clave de API” devuelve notas que discuten la gestión de claves de API, no notas que contienen claves reales.


Lo que falla: modos de fallo honestos

Después de cientos de consultas contra el índice de producción, cuatro patrones de fallo son claros.

El contenido superficial con muchas palabras clave supera al contenido profundo. Una nota corta etiquetada security, authentication, oauth con un resumen de tres oraciones puntúa más alto en BM25 que una inmersión profunda de 2.000 palabras sobre implementación de OAuth que usa la terminología una vez en la introducción y luego cambia a detalles específicos del protocolo. BM25 recompensa la frecuencia de términos relativa a la longitud del documento, una propiedad que Robertson y Zaragoza documentaron como el componente de “saturación de frecuencia de términos” del algoritmo.514 La nota superficial tiene mayor densidad de palabras clave. RRF corrige parcialmente el problema porque la búsqueda vectorial clasifica el contenido profundo más alto (el embedding captura la profundidad semántica), pero la nota superficial aún aparece en los resultados fusionados cuando probablemente no debería.

Los datos estructurados se indexan pobremente. Los archivos de configuración JSON, los bloques de frontmatter YAML y los fragmentos de código con nombres de variables producen coincidencias BM25 de baja calidad. Una búsqueda de “review configuration” coincide con cada archivo JSON que tiene una clave review. La búsqueda vectorial maneja los datos estructurados ligeramente mejor porque el embedding captura las relaciones clave-valor, pero el contenido estructurado es fundamentalmente más difícil de fragmentar que la prosa. Aplanar JSON a pares ruta-clave: valor antes de generar embeddings mejoraría la calidad de recuperación para notas con mucha configuración.

Los límites de fragmentación dividen el contexto. El fragmentador divide un párrafo que abarca el límite entre dos secciones H2 en dos fragmentos. Cada fragmento contiene la mitad de la explicación. Ninguno de los fragmentos genera buenos embeddings porque el embedding carece del contexto completo. El fragmentador mitiga el problema con el contexto del encabezado (llevando el encabezado padre a los metadatos), pero el texto del cuerpo aún pierde continuidad en el límite. Ventanas superpuestas ayudarían pero aumentan la cantidad de fragmentos y el tamaño de la base de datos.

La relevancia temporal es invisible. El recuperador no tiene noción de recencia. Una nota de hace 14 meses sobre una decisión de arquitectura temprana se clasifica igual que una nota de ayer sobre la implementación actual. Para una base de conocimiento que evoluciona, las notas más nuevas a menudo reemplazan a las más antiguas. El recuperador no lo sabe.


Lo que viene: la hoja de ruta de expansión

Cinco adiciones abordarían los modos de fallo y extenderían las capacidades del sistema.

Capa de re-ranking con aprendizaje. Después de la fusión RRF, un re-ranker ligero podría ajustar las puntuaciones basándose en señales de metadatos: recencia de la nota, relevancia de etiquetas al dominio de la consulta, densidad de enlaces (las notas con muchos enlaces suelen ser más autoritativas). El re-ranker se ejecutaría sobre los 30 mejores resultados fusionados, no sobre el corpus completo, manteniendo la latencia por debajo de la línea base de 23ms.

Clasificación de intención de consulta. Diferentes consultas necesitan diferentes estrategias de recuperación. Una búsqueda de identificador exacto (_rrf_fuse) debería ponderar BM25 fuertemente. Una pregunta conceptual (“how does review handle disagreements”) debería ponderar la búsqueda vectorial. Un clasificador ligero que ajuste bm25_weight y vec_weight por consulta mejoraría la precisión sin cambiar la arquitectura de fusión.

Decaimiento temporal. Ponderar las notas recientes ligeramente más alto para consultas sobre el estado actual. Una función de decaimiento aplicada post-fusión reduciría la puntuación de los fragmentos de archivos modificados por última vez hace más de N meses. La marca de tiempo mtime_ns ya existe en el esquema; el decaimiento solo necesita una función de ponderación en el recuperador.

Arnés de evaluación con consultas doradas. El sistema actualmente no tiene medición automatizada de calidad. Un conjunto de 50-100 pares consulta-respuesta curados permitiría pruebas de regresión de calidad de recuperación: ejecutar el conjunto de pruebas después de cualquier cambio en la fragmentación, embeddings o parámetros de fusión y verificar que el recall@10 no se degrade. El benchmark BEIR demostró que los sistemas de recuperación pueden variar en más de 20 puntos en nDCG@10 entre diferentes distribuciones de consultas, subrayando la necesidad de evaluación específica por dominio.19 Sin un conjunto dorado, las mejoras son anecdóticas.

Indexación de relaciones entre notas. Los wiki-links de Obsidian ([[note-name]]) codifican relaciones explícitas entre notas. El sistema actual ignora completamente la estructura de enlaces. Indexar los destinos de enlaces como metadatos permitiría al recuperador impulsar fragmentos de notas a las que muchas otras notas con alta puntuación enlazan, similar a PageRank para la bóveda.

El análisis de topología del espacio de embeddings que ejecuté sobre la bóveda completa revela dónde estas mejoras tendrían el mayor impacto. Los clusters densos (herramientas de IA, seguridad) ya se recuperan bien porque la terminología es consistente. Las regiones puente escasas entre clusters son donde el recuperador más tiene dificultades, y donde la indexación de relaciones y la clasificación de intención proporcionarían las mayores ganancias.


Preguntas frecuentes

¿Por qué SQLite en lugar de una base de datos vectorial dedicada?

Toda la pila de recuperación se ejecuta en un archivo con cero dependencias externas. El modo WAL de SQLite maneja lecturas concurrentes desde múltiples sesiones de Claude Code. La extensión sqlite-vec agrega búsqueda KNN vectorial sin requerir una instancia separada de Pinecone, Weaviate o Qdrant.4 Con 49.746 fragmentos, la latencia de consulta es de 23ms.1 Una base de datos vectorial dedicada agregaría complejidad operativa (alojamiento, respaldos, autenticación) para una base de conocimiento de un solo usuario que cabe en 83 MB.

¿Por qué Model2Vec en lugar de embeddings de OpenAI o un modelo más grande?

Tres razones: latencia, privacidad y costo. Model2Vec se ejecuta localmente a velocidad de CPU sin llamada de red.3 Las notas personales nunca salen de la máquina. Los embeddings basados en API costarían aproximadamente $0,30 por reindexación completa para el tamaño actual de la bóveda,11 insignificante de forma aislada, pero la latencia de ida y vuelta a través de 49.746 fragmentos y la exposición de privacidad del contenido personal son los costos reales.

¿Qué es Reciprocal Rank Fusion y cuándo debería usarlo?

RRF no requiere datos de entrenamiento, ni calibración de puntuaciones, ni ajuste de hiperparámetros más allá de la constante k.7 Un modelo de fusión aprendido requeriría juicios de relevancia etiquetados para el entrenamiento, que no existen para una base de conocimiento personal. RRF es el método de fusión con la barrera más baja para producir resultados útiles. Use RRF cuando combine listas ordenadas de métodos de recuperación que producen tipos de puntuación incompatibles.

¿Cómo se conecta un recuperador local a Claude Code?

Un hook PreToolUse llama al método search() del recuperador con el prompt actual, formatea los mejores resultados como un bloque de contexto con rutas de archivo y encabezados de sección, e inyecta el bloque en la conversación. El agente ve fragmentos enfocados, no archivos sin procesar. Un parámetro max_tokens asegura que el contexto inyectado se ajuste a un presupuesto.

¿Cómo se evita que los secretos se indexen en un sistema de recuperación?

Ejecute un filtro de credenciales en cada fragmento antes del almacenamiento. El filtro en este sistema coincide con 21 patrones específicos de proveedores y 11 detectores genéricos para JWTs, tokens bearer y claves privadas.20 Reemplaza el contenido coincidente con tokens [REDACTED:pattern-name] y se ejecuta antes de la generación de embeddings, de modo que las representaciones vectoriales nunca codifican patrones de credenciales.


Referencias


  1. Datos de producción del autor. 49.746 fragmentos, 16.894 archivos, base de datos SQLite de 83,56 MB, 7.771 señales procesadas a lo largo de 14 meses. Latencia de consulta (23ms) medida mediante time.perf_counter() en retriever.py, abarcando la ruta completa de búsqueda: consulta BM25, embedding de consulta vía Model2Vec, búsqueda KNN vectorial y fusión RRF. grep -rl medido en 11-66 segundos según la frecuencia del término (Apple M3 Pro, APFS). Reindexación completa medida en ~4 minutos en Apple M3 Pro. Incremental medida en <10 segundos para cambios diarios típicos. La búsqueda solo FTS5 se volvió inutilizable para el autor por encima de ~3.000 archivos debido a las tasas de colisión de palabras clave. 

  2. Hilo de HN: “Stop Burning Your Context Window”. Comentarios de danw1979 y tclancy solicitando un artículo detallado. 

  3. Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. El modelo potion-base-8M usa embeddings de palabras estáticos destilados de un sentence transformer, produciendo vectores de 256 dimensiones sin ejecutar capas de atención. 

  4. sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. Proporciona tablas virtuales vec0 para búsqueda KNN vectorial dentro de SQLite, usando la misma interfaz de consulta que las tablas estándar. 

  5. SQLite FTS5 Extension. Documentación de SQLite. FTS5 proporciona búsqueda de texto completo con ranking BM25, tablas con sincronización de contenido y pesos de columna configurables mediante la función auxiliar bm25()

  6. Reimers, N. y Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. Trabajo fundacional sobre similitud semántica densa para recuperación de texto, estableciendo el enfoque de búsqueda vectorial usado en sistemas de recuperación híbridos. 

  7. Cormack, G.V., Clarke, C.L.A. y Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. Introduce RRF con k=60 como un método libre de parámetros para combinar listas ordenadas que supera a los modelos de fusión entrenados. 

  8. Implementación del autor. chunker.py divide en límites H2 en la función _split_at_headings, con respaldo a división H3 y luego por párrafo para secciones que exceden los 2.000 caracteres. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000. index_vault.py genera embeddings en lotes de 64 (BATCH_SIZE=64). 

  9. van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. Describe el enfoque de destilación que produce embeddings estáticos a partir de sentence transformers con aceleración de inferencia de 50-500x. 

  10. Medición del autor. Vectores de 256 dimensiones con 49.746 fragmentos producen 83 MB de SQLite. Extrapolando a vectores de 768 dimensiones: ~215 MB. A 1024 dimensiones: ~280 MB. La mejora marginal de calidad en fragmentos cortos de markdown (promedio 200-400 palabras) no justifica el aumento de almacenamiento y latencia. 

  11. OpenAI Embeddings Pricing. text-embedding-3-small: $0,02 por millón de tokens. Costo estimado de la bóveda por reindexación completa: ~$0,30 basado en una longitud promedio de fragmento de ~200 tokens. 

  12. SQLite Write-Ahead Logging. Documentación de SQLite. El modo WAL permite lectores concurrentes con un solo escritor, adecuado para el patrón de acceso con predominio de lectura del recuperador. 

  13. Traza de consulta del autor. Ejecutó “PostToolUse hook for context compression” contra los modos solo BM25, solo vectorial e híbrido. Resultados capturados de retriever.py con el campo method rastreando qué ruta de búsqueda produjo cada resultado. 

  14. Robertson, S. y Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. Revisión de la familia de funciones de ranking BM25 y sus fundamentos teóricos. 

  15. Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. Demostró que las representaciones densas aprendidas superan a BM25 en un 9-19% en benchmarks de QA de dominio abierto, estableciendo la recuperación densa como complemento de la búsqueda léxica. 

  16. Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. Análisis de recuperación híbrida sparse-densa en MS MARCO, mostrando mejoras consistentes sobre los enfoques de modalidad única. 

  17. MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M obtiene 50,03 en MTEB promedio vs 56,09 para all-MiniLM-L6-v2 (89,2% de retención). Desglose por tarea: Classification 64,44, Clustering 32,93, Retrieval 31,71, STS 73,24. Fuente: Resultados de Model2Vec

  18. Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. Revisión de arquitecturas RAG incluyendo análisis de estrategias de fragmentación y su impacto en la calidad de la recuperación. 

  19. Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. Demuestra alta varianza en el rendimiento de recuperación entre dominios, subrayando la necesidad de evaluación específica por dominio. 

  20. Configuración del autor e implementación del filtro de credenciales. memory-config.json define 22 entradas allowed_folders y 5 entradas excluded_always. credential_filter.py define 21 CREDENTIAL_PATTERNS específicos de proveedores (OpenAI hasta Turnstile) más 9 patrones genéricos de una línea (URLs de BD, tokens bearer, JWTs, contraseñas, secretos, claves de API, tokens de autenticación, secretos base64) y 2 patrones multilínea (claves privadas RSA/SSH, claves PGP). Total: 32 patrones. 

Artículos relacionados

The Blind Judge: Scoring Claude Code vs Codex in 36 Duels

Claude Code vs Codex CLI, scored blind on 5 dimensions across 36 duels. The winner matters less than the synthesis combi…

14 min de lectura

Thinking With Ten Brains: How I Use Agent Deliberation as a Decision Tool

You cannot debias yourself by trying harder. 10 AI agents debating each other is a structural intervention for better de…

15 min de lectura

Topology of a Second Brain: What 15,000 Signals Look Like in Embedding Space

15,800 notes in embedding space reveal three knowledge topologies. Each has different failure modes practitioners can di…

15 min de lectura