Skip to main content
Glama
adborroto

semantic-search-mcp

by adborroto

semantic-search-mcp

CI Security npm node License: MIT

Un motor de recuperación RAG-lite pequeño y autónomo: indexa archivos en disco y responde "qué es semánticamente relevante para esta consulta" — nada más. No llama a un LLM y no genera respuestas. Devuelve los fragmentos de texto más relevantes (archivo, línea, puntuación) para que quien lo consuma — un humano, un script o un LLM a través de MCP — decida qué hacer con ellos.

Todo se ejecuta localmente y sin conexión después de la primera ejecución:

  • Embeddings: @huggingface/transformers ejecutando Xenova/all-MiniLM-L6-v2 con pesos cuantizados int8 en CPU. Sin GPU, sin clave API, sin llamadas de red en el momento de la consulta.

  • Almacén de vectores: @lancedb/lancedb — una base de datos vectorial incrustada y respaldada por archivos. Sin proceso de servidor, sin Docker.

  • Interfaces: una CLI y un servidor MCP stdio, para que cualquier agente compatible con MCP (Claude Code, Cursor, Zed, …) pueda buscar directamente en tu corpus.

Inicio rápido

npm install -g @adborroto/semantic-search-mcp

semantic-search add ~/code/my-project      # add a folder to the corpus
semantic-search index                      # embed it (incremental on later runs)
semantic-search search "how does the retry logic work"

Esa es toda la configuración. No hay ningún archivo de configuración que escribir a mano — add lo crea y lo gestiona por ti. Para probarlo sin instalar nada:

npx @adborroto/semantic-search-mcp add ~/code/my-project

Aviso sobre el tamaño de instalación: ~950MB de dependencias, más un modelo de embedding de ~25MB descargado en el primer uso. Casi todo son binarios nativos que no puedes evitar en esta capa — @lancedb/lancedb (~430MB incluyendo su binario de plataforma) y el runtime ONNX (~300MB, que incluye compilaciones para cada plataforma en un solo paquete). Ambos se almacenan en caché una vez; todo después de la primera ejecución funciona sin conexión.

Related MCP server: rag-retriever-mcp

Requisitos

  • Node.js >= 22 (node:sqlite, utilizado por el backend alternativo, solo es estable desde la 22).

  • ~950MB de disco para dependencias y ~25MB para el modelo de embedding, más aproximadamente 1–3 KB por fragmento indexado.

  • Sin GPU, sin servicios externos, sin servidor de base de datos.

Por qué "RAG-lite"

Un pipeline RAG completo es: recuperar fragmentos → alimentarlos a un LLM → el LLM escribe una respuesta. Este proyecto se detiene en el primer paso. Esto lo mantiene simple, rápido, económico de ejecutar y fácil de razonar — y se compone limpiamente con cualquier LLM o framework de agente que ya estés usando, en lugar de incluir su propia capa de generación dogmática.

Gestión del corpus

semantic-search add ~/code/api ~/notes     # add one or more folders
semantic-search list                       # show what's configured
semantic-search remove api                 # by folder name...
semantic-search remove ~/notes             # ...or by path
semantic-search config                     # where config + index actually live

add valida que cada ruta sea un directorio real, lo resuelve a una ruta absoluta y omite duplicados (incluyendo el mismo directorio alcanzado a través de un enlace simbólico). remove también purga los fragmentos de esa carpeta del índice, por lo que su contenido deja de aparecer en los resultados — pasa --keep-index si quieres eliminarlo del corpus pero mantenerlo buscable.

Dónde se almacenan las cosas

La configuración y el índice siguen la especificación del directorio base XDG, por lo que sobreviven a las actualizaciones y son compartidos por cada método de instalación:

Qué

Ubicación

Configuración

~/.config/semantic-search/config.json

Índice + caché del modelo

~/.local/share/semantic-search/

Anula cualquiera de ellos con SS_CONFIG_PATH, SS_INDEX_DIR, SS_MODEL_CACHE_DIR, o los estándar XDG_CONFIG_HOME / XDG_DATA_HOME. SS_STORE_BACKEND=sqlite fuerza el backend alternativo.

El índice contiene el texto literal de todo lo que indexaste. Si apuntas esto a código privado, ~/.local/share/semantic-search/ contiene ese contenido en texto plano. Nunca lo confirmes y no lo adjuntes a un informe de error.

Cada opción está documentada en src/config.js — tamaño de fragmentos, patrones de ignorar, nombre del modelo, top-k, concurrencia. Editar config.json directamente sigue funcionando para esos; add/remove conservan cualquier clave que no posean.

Uso

Índice

semantic-search index                      # all configured folders
semantic-search index ~/code/one-project   # just this folder, ignoring config
semantic-search index --force              # reprocess everything

La indexación es incremental: los archivos sin cambios se omiten por fecha de modificación, los archivos cuyo contenido realmente no cambió (solo tocados) omiten la re-embedding, y los archivos eliminados del disco se eliminan del índice. Solo se reprocesa lo que realmente cambió.

Con varias carpetas configuradas, index las recorre en secuencia con un encabezado por carpeta y un total combinado:

[1/3] my-api  /home/me/code/my-api  ─────────────────────────────
  ↺ indexed   src/auth/middleware.js  (8 chunks)
  2 indexed  1,203 skipped  16 chunks  4.1s

[2/3] my-app  /home/me/code/my-app  ─────────────────────────────
  ...

──────────────────────────────────────────────────────────────
total  5 indexed  3,891 skipped  0 deleted  41 chunks  12.3s

Cada llamada index <path> solo elimina entradas obsoletas para archivos bajo esa ruta, por lo que indexar la carpeta B nunca toca las entradas de la carpeta A.

Banderas útiles: --max-files <n> se detiene después de N archivos nuevos (limita la memoria en corpus enormes), --concurrency <n> establece el paralelismo, --verbose registra cada archivo en stderr.

Búsqueda

semantic-search search "how does the retry logic work" -k 5

Imprime una tabla con la ruta del archivo, el número de línea, la puntuación y una vista previa del texto.

La recuperación es híbrida: la consulta va a dos brazos independientes — una búsqueda vectorial sobre los embeddings y una búsqueda BM25 de texto completo sobre los mismos fragmentos — y las dos clasificaciones se fusionan con Fusión de Rango Recíproco. Los brazos fallan de manera diferente: el brazo vectorial pierde identificadores exactos, cadenas de error y claves de configuración sobre las que no tiene un control semántico; el brazo léxico pierde paráfrasis. Ejecutar ambos es una solución de recuperación, y fusionar por rango en lugar de puntuación evita que una puntuación BM25 ilimitada ahogue la similitud del coseno.

Establece "hybridSearch": false en config.json para recuperación solo vectorial, y "rrfK" para ajustar la constante de suavizado de rango de RRF (por defecto 60, del artículo).

Qué se indexa

Apúntalo a una carpeta y todo lo que contiene se indexa, recursivamente. No hay una lista blanca de extensiones de archivo "compatibles" — .dart, .kt, .java, .tsx, .sql, .erb y cualquier otro texto se indexan tal cual, con .pdf y .docx pasando primero por un analizador.

Se excluyen cuatro cosas:

  1. Lo que git ignora, si la carpeta es un repositorio git. Se respeta .gitignore a cualquier profundidad, junto con .git/info/exclude, tu archivo de exclusiones global y los patrones de negación (!keep.this). Esto se delega a git ls-files en lugar de reimplementarse, por lo que coincide exactamente con git — lo que significa que la salida generada y comercializada que tu proyecto ya ignora se mantiene fuera del índice sin que mantengas una segunda lista.

  2. Tus reglas .indexignore (ver más abajo), para contenido que está confirmado pero no debería ser buscable — fixtures, instantáneas, una plantilla de secretos verificada.

  3. Archivos binarios, por extensión (imágenes, archivos, fuentes, objetos compilados, pesos de modelos) y por contenido — un byte NUL en los primeros 4KB significa binario, la misma heurística que usa grep -I. Esto es una medida de seguridad para mantener los bytes no textuales fuera del tokenizador, no un juicio sobre lo que vale la pena indexar.

  4. Archivos de más de 500.000 bytes (maxFileSizeBytes), que es la protección principal contra un archivo generado de una línea y un megabyte que agote la memoria.

Los enlaces simbólicos se omiten en lugar de seguirse, por lo que un enlace colocado dentro de una carpeta no puede extraer contenido externo al índice.

Para las carpetas que no son repositorios git, no hay .gitignore en el que apoyarse, por lo que una pequeña lista incorporada (node_modules/, .git/, dist/, build/, coverage/, vendor/, …) sigue aplicándose.

Para excluir más, coloca un .indexignore de estilo gitignore en cualquiera de estos lugares:

  • dentro de una carpeta que indexas — los patrones son relativos a esa carpeta;

  • junto a tu configuración (~/.config/semantic-search/.indexignore) — se aplica en todas partes.

Consulta .indexignore.example para un punto de partida que cubre artefactos de compilación de iOS, Android, Flutter, Ruby y JVM.

Servidor MCP

semantic-search mcp

Inicia un servidor MCP stdio que expone seis herramientas.

search(query, k?) — búsqueda semántica, devuelve JSON sin procesar:

[{ filePath, text, score, offset, startLine }, ...]

gather(query, k?, contextLines?) — misma búsqueda, devuelta como un solo bloque de markdown formateado listo para colocar en una ventana de contexto:

### [1/5]  my-api  ·  src/auth/session.js  ·  line 42  ·  score 0.923
```
...chunk text...
```

contextLines (por defecto 0) lee N líneas adicionales alrededor de cada fragmento del archivo fuente — útil cuando un límite de fragmento corta el contexto que necesitas.

list_folders() — cada carpeta configurada con su nombre y ruta absoluta. Una buena primera llamada para que el agente sepa qué corpus existe.

cat_file(filePath, startLine?, endLine?) — lee un archivo por ruta absoluta, tal como lo devuelve search/gather. Limitado a las carpetas configuradas (ver Seguridad).

grep(pattern, folder?, fileGlob?, caseSensitive?, maxResults?) — búsqueda literal o regex en todo el corpus, para cuando necesitas coincidencias exactas en lugar de similitud. Filtrado contra la misma lista de archivos exacta que indexaría el indexador, por lo que los archivos ignorados por git y .indexignore no pueden filtrarse a través de la búsqueda de coincidencias exactas.

my-api  ·  src/auth/session.js:42  export function createSession(user) {

index(root?, force?, maxFiles?, concurrency?) — desencadena un reindexado incremental, para que un agente pueda actualizar el corpus sin recurrir a un shell.

Todas las herramientas de búsqueda comparten el mismo código de clasificación y resolución de archivos que la CLI; ninguna lo reimplementa.

Registro con un cliente MCP

Claude Code:

claude mcp add --scope user semantic-search -- semantic-search mcp
claude mcp list   # should show "✔ Connected"

Cualquier cliente que acepte una definición de servidor JSON:

{
  "mcpServers": {
    "semantic-search": {
      "command": "semantic-search",
      "args": ["mcp"]
    }
  }
}

Prefiere una instalación global sobre npx aquí: un npx simple vuelve a resolver el paquete cada vez que se inicia el servidor, añadiendo latencia de inicio y recogiendo actualizaciones sin previo aviso. Si usas npx, fija la versión — npx -y @adborroto/semantic-search-mcp@0.1.0 mcp.

Los nuevos servidores MCP normalmente solo se recogen cuando se inicia una sesión, así que inicia una sesión nueva después de registrarlo.

Seguridad

Esta es una herramienta local para un solo usuario con un modelo de confianza simple: cualquier cosa dentro de una carpeta configurada es legible por cualquier cliente MCP que pueda alcanzar el servidor.

  • cat_file rechaza rutas fuera de las carpetas configuradas, resolviendo primero los enlaces simbólicos para que un enlace colocado dentro de una carpeta no pueda usarse para escapar de ella.

  • grep se filtra contra la misma lista de archivos que construye el indexador — las reglas de ignorar de git más tu .indexignore — por lo que los archivos excluidos deliberadamente de la indexación no se filtran a través de la búsqueda de coincidencias exactas.

  • Los subprocesos se generan con arrays argv (nunca un shell), por lo que los patrones no pueden inyectar comandos.

Dado eso, no lo apuntes a un corpus que no le entregarías a tu proveedor de LLM — los fragmentos se devuelven a cualquier cliente que los haya solicitado. Consulta SECURITY.md.

Cómo funciona

Descubrimiento de archivos

La regla es "indexar todo lo que hay debajo de la carpeta", y la única parte interesante es qué no indexar. En lugar de reimplementar la semántica de ignorar de git — archivos .gitignore anidados, negaciones, info/exclude, el archivo de exclusiones global — una raíz git se enumera con:

git ls-files -z --cached --others --exclude-standard

Archivos rastreados más archivos no rastreados pero no ignorados, limitados al directorio en el que se ejecuta. Cualquier cosa que git ignora está ausente por construcción. Las carpetas que no son git recurren a un recorrido recursivo simple con la lista de patrones incorporada.

La misma función respalda tanto el indexador como la herramienta MCP grep (src/ignoreRules.js). Eso es deliberado: grep recurre a un grep -r real, que felizmente reporta aciertos dentro de la salida de compilación ignorada por git, por lo que filtra sus resultados contra la propia lista de archivos del indexador. Si los dos derivaran sus reglas por separado, divergirían y la lista de ignorar dejaría de ser un límite.

Recuperación híbrida

Una consulta se ejecuta a través de dos brazos en paralelo:

  • Vectorial — incrustar la consulta, tomar los vecinos más cercanos por distancia coseno, luego reordenar esa lista con un pequeño impulso léxico para fragmentos que contienen los términos literales de la consulta.

  • Léxico — BM25 sobre el mismo texto del fragmento, a través de un índice de texto completo de LanceDB (el respaldo sqlite calcula BM25 en JS, ya que no se garantiza que node:sqlite incluya FTS5).

Las dos clasificaciones se fusionan con RRF: cada lista contribuye con 1 / (60 + rank) a cada fragmento que devuelve, y las contribuciones se suman. Fusionar por rango en lugar de por puntuación es la clave: el coseno se encuentra en [-1, 1] mientras que BM25 no tiene límite superior, por lo que sumar o promediar puntuaciones brutas permite que un brazo supere silenciosamente al otro dependiendo del tamaño del corpus.

Por qué dos brazos: un refuerzo léxico aplicado a la salida del brazo vectorial solo puede reordenar lo que la consulta vectorial ya devolvió. Un fragmento cuya única señal es una coincidencia exacta de términos —un código de error, un nombre de símbolo, una clave de configuración sin vecindario semántico— era inalcanzable si quedaba fuera del grupo vectorial. El brazo léxico lo recupera de forma independiente. Es una corrección de recuperación, no de reordenación, y es por eso que las puntuaciones de búsqueda ahora parecen 0.03 en lugar de 0.9: son sumas de RRF, no similitudes de coseno. Solo su ordenación es significativa.

El índice de texto completo se reconstruye al final de cada ejecución de indexación, porque un índice FTS no cubre las filas añadidas después de su construcción; de lo contrario, los fragmentos que una ejecución acaba de escribir serían invisibles para el brazo léxico.

Fragmentación

El texto se divide en párrafos y luego se empaqueta de forma codiciosa en fragmentos de aproximadamente 200 tokens con ~35 tokens de superposición, contados con el tokenizador real del modelo de incrustación en lugar de una aproximación por conteo de caracteres. Esto no es arbitrario: all-MiniLM-L6-v2 tiene una ventana de 256 tokens y trunca silenciosamente cualquier cosa más larga, por lo que los fragmentos se dimensionan para caber dentro de ella con margen para los tokens [CLS]/[SEP]. La superposición también está limitada para que la superposición más el siguiente párrafo nunca puedan superar ese límite; de lo contrario, la cola de un fragmento se descartaría al incrustar mientras aún se devuelve mediante search.

Un solo párrafo más grande que el límite estricto (un paquete minimizado, una línea de registro gigante) recurre al empaquetado a nivel de palabra con la misma lógica de superposición, y cualquier "palabra" individual de más de 500 caracteres se divide primero, para que nunca se entregue nada enorme al tokenizador de una sola vez.

Los recuentos de tokens se calculan una vez por párrafo/palabra y se almacenan en caché para reutilizarlos durante el cálculo de superposición. Una versión anterior volvía a tokenizar en cada búsqueda de superposición, lo que funcionaba bien en entradas pequeñas pero causaba un uso descontrolado de CPU y un crecimiento de memoria de varios GB en repositorios grandes. Si amplías el fragmentador, conserva esa propiedad.

Reindexación incremental

No hay un manifiesto separado: el almacén de vectores es el manifiesto. Cada fragmento almacenado lleva el mtimeMs de su archivo fuente y un hash de contenido sha256. En cada ejecución:

  1. Si el mtime del archivo en disco coincide con el almacenado, se omite sin leer el archivo.

  2. Si el mtime cambió pero el hash de contenido es idéntico (un touch), se omite la reincrustación.

  3. De lo contrario, se eliminan los fragmentos antiguos de ese archivo y se insertan los recién incrustados.

  4. Después del listado, cualquier ruta indexada que ya no esté en disco (y bajo la raíz que se está indexando) se elimina.

Backends de almacenamiento

El predeterminado es LanceDB: incrustado, respaldado por archivos, búsqueda vectorial real. Un respaldo de node:sqlite + coseno por fuerza bruta (src/store/sqliteFallbackStore.js) implementa la misma interfaz (src/store/vectorStore.js) para entornos donde el enlace nativo de LanceDB no se carga — contenedores en modo aislado, arquitecturas inusuales. Cambia con SS_STORE_BACKEND=sqlite.

El respaldo realiza un escaneo completo de la tabla por búsqueda: está bien para decenas de miles de fragmentos, no más allá. La métrica predeterminada de LanceDB es L2, no coseno, por lo que este proyecto establece explícitamente .distanceType('cosine') en cada consulta, ya que las incrustaciones se comparan como vectores normalizados.

Estructura del proyecto

src/
  config.js            Defaults + config file resolution (XDG) — the only source of tunables
  configFile.js        Read/modify/write the config file (backs add/remove/list)
  embeddings.js        transformers.js pipeline + tokenizer (lazy singletons)
  chunker.js           Token-aware paragraph packing with overlap
  ignoreRules.js       What is indexable: git ignore rules + .indexignore + binary filter,
                       shared by the indexer and grep so they can't drift apart
  safePath.js          Path confinement for the MCP file-reading tools
  version.js           Version read from package.json
  extractors/          text (anything not binary), pdf (pdf-parse), docx (mammoth)
  store/
    vectorStore.js        Storage interface + backend selector
    lancedbStore.js       LanceDB implementation (default)
    sqliteFallbackStore.js node:sqlite + manual cosine fallback
  indexer.js           List + extract + chunk + embed + incremental upsert/prune
  search.js            Hybrid retrieval: vector + BM25 arms fused with RRF — shared by CLI and MCP
  mcp-server.js        MCP stdio server: the six tools above
  index.js             CLI entrypoint (commander)
scripts/index-all.sh   Batched indexing for very large corpora on constrained hosts (Linux)

Desarrollo

git clone https://github.com/adborroto/semantic-search-mcp.git
cd semantic-search-mcp
npm install
npm test              # unit + end-to-end (node:test, no framework)
npm run test:unit     # skip the slow end-to-end test
npm run lint

Un config.json en la raíz del checkout tiene prioridad sobre la ubicación XDG, para que puedas desarrollar con un corpus de prueba sin tocar tu configuración real. Las pruebas siempre escriben en directorios temporales. Consulta CONTRIBUTING.md.

Fuera del alcance (por diseño)

  • Generación de respuestas. Esto devuelve fragmentos, no respuestas. Aliméntalos a un LLM tú mismo.

  • Reordenación con un segundo modelo. La recuperación híbrida más RRF no tiene dependencias y logra la mayor parte del objetivo, pero no es un reordenador de codificador cruzado.

  • Una interfaz web. Solo CLI y MCP.

  • Corpus a escala masiva. Construido para un corpus de documentación y código de tamaño personal o de equipo: decenas de miles de fragmentos, no millones. Ambos backends asumen esa escala.

Licencia

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    Local-first RAG indexing and semantic search MCP server. Enables document retrieval and context-aware queries using local embedding models.
    3
    13 npm
    MIT
  • F
    license
    A
    quality
    D
    maintenance
    A local-first document retrieval engine that mounts as an MCP tool for agents to index files, search for relevant passages, and let the agent's own LLM answer.
    4
    -
  • A
    license
    A
    quality
    B
    maintenance
    Indexes local Markdown/text files into a SQLite database with vector embeddings and provides MCP tools for semantic search without cloud dependencies.
    3
    AGPL 3.0