RAG & Documentos
¿Qué base de datos vectorial usar en local para RAG? sqlite-vec, Chroma, LanceDB, FAISS y DuckDB medidos con 100.000 párrafos en español
Con 100.000 vectores, la búsqueda exacta de FAISS responde en 1,6 ms sin perder vecinos. Chroma tarda 0,6 ms pero devuelve el 92 %, y el índice por defecto de LanceDB solo el 28,7 %.
- Fecha
- 24 oct 2026
- Lectura
- 8 min
- Resultado
- 100 % de recall con FAISS exacto en 1,647 ms • Chroma: 0,595 ms y 92,02 % • LanceDB IVF_PQ por defecto: 28,71 %
Hasta 100.000 fragmentos, para un RAG en local no hace falta un índice aproximado: la búsqueda exacta de FAISS (IndexFlatIP) devuelve los 10 vecinos correctos (100 % de recall) en 1,647 ms por consulta. Chroma responde en 0,595 ms, pero con su configuración por defecto devuelve solo el 92,02 % de los vecinos de verdad. El índice que LanceDB crea por defecto (IVF_PQ) baja al 28,71 %. sqlite-vec y DuckDB son exactos, pero tardan 55,319 ms y 44,606 ms. Medido el 24 de octubre de 2026 con 100.000 párrafos de Wikipedia en español, 1.000 consultas y 5 repeticiones por caso.
«¿Chroma, LanceDB, sqlite-vec o FAISS?» es una de las primeras dudas al montar un RAG sin servidor. Las comparativas que circulan son páginas de los propios proveedores o artículos sin datos que se puedan reproducir, y casi nunca miden si la base devuelve los vecinos correctos. Aquí está la medida, con el código publicado.
Definiciones
- Base vectorial embebida: una librería que guarda los embeddings en un fichero o carpeta local y los busca dentro del mismo proceso, sin servidor aparte.
- Búsqueda exacta (fuerza bruta): compara la consulta con todos los vectores. Nunca se equivoca y su coste crece con el número de vectores.
- Índice aproximado (HNSW, IVF_PQ): una estructura que evita comparar con todo. Es más rápida a gran escala, pero puede dejarse fuera algunos de los vecinos más cercanos.
- Recall@10: de los 10 párrafos más parecidos a la consulta (calculados de forma exacta con numpy), cuántos devuelve el motor. Con un 92,02 %, se quedan fuera 7,98 de cada 100 fragmentos que el RAG debería pasar al LLM.
- Latencia p50 / p95: tiempo de una consulta en la mediana y en el percentil 95 de 1.000 consultas lanzadas de una en una.
Hipótesis
- Con 100.000 vectores, los motores con índice aproximado (Chroma, FAISS HNSW, LanceDB IVF_PQ) son mucho más rápidos que los exactos.
- Su configuración por defecto conserva casi todos los vecinos (recall por encima del 95 %).
- sqlite-vec y DuckDB, al trabajar desde SQL, son algo más lentos que la búsqueda en memoria.
Montaje
- Datos: 479.809 párrafos de 300 a 1.500 caracteres sacados de un fichero de la Wikipedia en español (volcado del 1 de noviembre de 2023), barajados con semilla fija. Se indexan 100.000 (548,97 caracteres de media) y, como corpus pequeño, los primeros 10.000. Las consultas son la primera frase de otros 1.000 párrafos.
- Embeddings:
intfloat/multilingual-e5-small(384 dimensiones, normalizados). Calcular los de los 100.000 párrafos llevó 352,8 s en la GPU del portátil. - Verdad: los 10 vecinos exactos de cada consulta por similitud coseno, calculados con numpy.
- Motores: numpy y FAISS
IndexFlatIP(exactos, en memoria y guardados en un fichero), FAISSIndexHNSWFlat(M = 32), sqlite-vec (tablavec0con distancia coseno), DuckDB (columnaFLOAT[384]yORDER BY array_inner_product, sin la extensión VSS), Chroma (PersistentClient) y LanceDB, sin índice y concreate_index()por defecto (IVF_PQ). Todos con su configuración por defecto. Para ver cuánto se recupera ajustando solo la consulta, dos variantes: FAISS HNSW conefSearch = 64y LanceDB IVF_PQ conrefine_factor = 10. - Qué se mide: indexado (insertar y dejarlo persistido en disco), tamaño en disco, latencia de 1.000 consultas con k = 10 tras 20 de calentamiento, recall@10 y memoria extra (pico de RSS del proceso menos el RSS antes de indexar).
- Repeticiones: 5 por motor y tamaño, cada una en un proceso nuevo y en una carpeta nueva, en orden aleatorio e intercalado.
- Equipo: portátil Apple M1 Max con 64 GB, macOS 26.6.2 y Python 3.12.13.
Resultados
100.000 vectores (mediana de 5 repeticiones)
| Motor | Recall@10 | Latencia p50 | Latencia p95 | Indexado | Disco | Memoria extra |
|---|---|---|---|---|---|---|
| FAISS IndexFlatIP (exacto) | 100 % | 1,647 ms | 1,765 ms | 0,04 s | 153,6 MB | 309,4 MB |
| numpy (exacto) | 100 % | 4,205 ms | 4,533 ms | 0,02 s | 153,6 MB | 317,3 MB |
| LanceDB sin índice (exacto) | 100 % | 15,816 ms | 16,122 ms | 0,11 s | 153,8 MB | 1081,4 MB |
| DuckDB, SQL sin extensión (exacto) | 100 % | 44,606 ms | 45,362 ms | 3,20 s | 156,5 MB | 576,3 MB |
| sqlite-vec (exacto) | 100 % | 55,319 ms | 56,244 ms | 0,97 s | 156,6 MB | 168,4 MB |
| FAISS HNSW, efSearch 64 | 92,57 % | 0,242 ms | 0,290 ms | 3,67 s | 180,8 MB | 339,0 MB |
| Chroma (HNSW por defecto) | 92,02 % | 0,595 ms | 0,682 ms | 11,84 s | 196,1 MB | 621,2 MB |
| FAISS HNSW (por defecto) | 74,64 % | 0,083 ms | 0,118 ms | 3,68 s | 180,8 MB | 338,6 MB |
| LanceDB IVF_PQ, refine_factor 10 | 68,45 % | 3,518 ms | 3,705 ms | 12,47 s | 156,8 MB | 1270,0 MB |
| LanceDB IVF_PQ (por defecto) | 28,71 % | 2,393 ms | 2,535 ms | 12,41 s | 156,8 MB | 1284,1 MB |
10.000 vectores (mediana de 5 repeticiones)
| Motor | Recall@10 | Latencia p50 | Latencia p95 | Indexado | Disco | Memoria extra |
|---|---|---|---|---|---|---|
| FAISS IndexFlatIP (exacto) | 100 % | 0,311 ms | 0,316 ms | 0,004 s | 15,4 MB | 32,5 MB |
| numpy (exacto) | 100 % | 0,332 ms | 0,366 ms | 0,002 s | 15,4 MB | 39,5 MB |
| LanceDB sin índice (exacto) | 100 % | 4,041 ms | 4,220 ms | 0,016 s | 15,4 MB | 126,1 MB |
| sqlite-vec (exacto) | 100 % | 5,478 ms | 5,538 ms | 0,098 s | 16,0 MB | 28,7 MB |
| DuckDB, SQL sin extensión (exacto) | 100 % | 7,900 ms | 8,097 ms | 0,931 s | 13,6 MB | 137,2 MB |
| FAISS HNSW, efSearch 64 | 98,35 % | 0,109 ms | 0,126 ms | 0,166 s | 18,1 MB | 36,9 MB |
| Chroma (HNSW por defecto) | 98,00 % | 0,369 ms | 0,402 ms | 0,699 s | 29,3 MB | 126,9 MB |
| FAISS HNSW (por defecto) | 86,13 % | 0,037 ms | 0,047 ms | 0,166 s | 18,1 MB | 36,9 MB |
| LanceDB IVF_PQ, refine_factor 10 | 80,21 % | 2,283 ms | 2,457 ms | 1,258 s | 16,0 MB | 142,3 MB |
| LanceDB IVF_PQ (por defecto) | 34,34 % | 1,164 ms | 1,258 ms | 1,240 s | 16,0 MB | 140,9 MB |
La dispersión entre repeticiones es baja. Con 100.000 vectores, la latencia p50 de sqlite-vec va de 55,197 a 56,191 ms y la de Chroma, de 0,585 a 0,607 ms. El recall de Chroma va del 91,92 % al 92,05 %. El de LanceDB IVF_PQ, del 28,58 % al 29,32 %, porque el índice se entrena de nuevo en cada repetición.
Lo que se ve en las tablas:
- El índice aproximado pierde vecinos al crecer el corpus. Chroma pasa del 98 % con 10.000 vectores al 92,02 % con 100.000. FAISS HNSW por defecto (
efSearch = 16) pasa del 86,13 % al 74,64 %: pierde 25,36 puntos frente a la búsqueda exacta. SubirefSearcha 64 recupera hasta el 92,57 % y sigue en 0,242 ms. - El IVF_PQ por defecto de LanceDB pierde 71,29 puntos de recall con 100.000 vectores (28,71 %). Reordenar con los vectores completos (
refine_factor = 10) lo sube al 68,45 %, todavía por debajo de cualquier opción exacta y más lento que FAISS exacto. Además, tarda 12,41 s en crear el índice. - La búsqueda exacta escala bien hasta aquí. FAISS
IndexFlatIPpasa de 0,311 ms a 1,647 ms al multiplicar por 10 los vectores: 598 consultas por segundo en un solo hilo de llamadas. Es más rápido que numpy (4,205 ms) con los mismos vectores y el mismo resultado. - Hacerlo desde SQL tiene un precio. sqlite-vec necesita 13,2 veces la latencia de numpy con 100.000 vectores (18 consultas por segundo) y DuckDB, 10,6 veces. A cambio, sqlite-vec es lo que menos memoria añade (168,4 MB con 100.000 vectores) y guarda los vectores en el mismo fichero que el resto de los datos de la aplicación.
- LanceDB y Chroma usan más memoria. LanceDB sin índice añade 1081,4 MB al consultar 100.000 vectores (153,8 MB en disco). Chroma añade 621,2 MB y ocupa en disco 196,1 MB, 1,28 veces lo que ocupan los vectores en bruto.
Qué significa
- Hipótesis 1: se cumple, pero la diferencia no importa en un RAG. Chroma (0,595 ms) y FAISS HNSW (0,083 ms) son mucho más rápidos que sqlite-vec (55,319 ms). Aun así, cualquiera de los tres es pequeño frente a lo que tarda un LLM en responder. El cuello de botella está en la generación, no en la búsqueda.
- Hipótesis 2: no se cumple. Ningún índice aproximado llega al 95 % con 100.000 vectores en su configuración por defecto. Lo que se pierde no da error: son fragmentos relevantes que no llegan al contexto, y la respuesta empeora sin aviso.
- Hipótesis 3: se cumple. sqlite-vec y DuckDB son exactos pero tardan de 10 a 13 veces lo que tarda numpy: recorren todos los vectores desde SQL.
Cuándo usar cada opción
- Hasta unos 100.000 fragmentos y un solo proceso: búsqueda exacta con FAISS
IndexFlatIP(o numpy si no quieres dependencias). Recall del 100 %, 1,647 ms y un fichero de 153,6 MB. Es lo que recomendamos por defecto para un RAG de documentación interna, contratos o tickets. - Si los datos de la aplicación ya están en SQLite: sqlite-vec. Con 10.000 fragmentos responde en 5,478 ms y añade 28,7 MB de memoria, sin un segundo almacén que sincronizar. Con 100.000 sigue siendo exacto, pero ya tarda 55,319 ms por consulta.
- Si necesitas consultas por debajo del milisegundo con mucho volumen: un índice HNSW (FAISS o Chroma), ajustando
efSearch(o elefde búsqueda de Chroma) y midiendo el recall con tus datos. No te fíes de los valores por defecto. - LanceDB: sin índice se comporta como una búsqueda exacta (100 %, 15,816 ms) y aporta formato columnar, versiones y filtros. Su índice IVF_PQ por defecto no conviene para RAG por debajo de 100.000 vectores sin ajustar sus parámetros y medir el recall.
- DuckDB sin extensiones: útil para análisis puntuales sobre embeddings que ya tienes en una tabla, no para servir consultas (44,606 ms con 100.000 vectores).
Limitaciones
- Un solo portátil Apple Silicon con otras aplicaciones abiertas. Las cifras valen para comparar entre sí y para el orden de magnitud, no como latencias absolutas de un servidor.
- Dos tamaños (10.000 y 100.000 vectores) de 384 dimensiones. Con millones de vectores o con modelos de 1.024 dimensiones o más, la búsqueda exacta deja de ser razonable y el equilibrio cambia a favor de los índices.
- Configuración por defecto, salvo dos variantes ajustadas en la consulta. No se probó el ajuste de construcción (M,
ef_construction, particiones y subvectores de IVF_PQ), ni el índice IVF_HNSW de LanceDB, ni la extensión VSS de DuckDB. sqlite-vec 0.1.9 solo hace búsqueda exacta. - Consultas de una en una y sin filtros por metadatos. No se midieron las consultas en lote, la concurrencia, las actualizaciones y borrados, ni la búsqueda híbrida con texto completo.
- La memoria extra incluye las copias que cada librería hace al insertar (por ejemplo, la tabla de Arrow en LanceDB) y lo que queda reservado tras las consultas. Es el pico del proceso, no lo que ocupa el índice en reposo.
- La reapertura del índice se guardó en los resultados en bruto, pero no se publica: Chroma reutiliza el cliente dentro del mismo proceso, así que esa cifra no mide una apertura en frío.
- El recall se mide contra los vecinos exactos del mismo modelo de embeddings, no contra juicios de relevancia humanos. Mide cuánto se pierde por usar un índice, no la calidad del RAG completo.
Cómo repetirlo
Código, entorno y resultados en bruto (cada medida de cada repetición, el resumen con mediana, mínimo, máximo y desviación, y los cocientes): yeswelab-laboratorio/bases-vectoriales-locales-rag en GitHub. Descarga el fichero de Wikipedia y el modelo de Hugging Face sin cuenta. En un M1 Max, los embeddings tardan unos 6 minutos y las 100 medidas, unos 25.
Relacionado
- ¿Qué tamaño de chunk usar en un RAG en español?
- ¿Qué parser de PDF usar para RAG en español?
- SQLite en modo WAL frente a PostgreSQL para automatizaciones locales
¿Vas a montar un RAG con tus documentos y no sabes si te basta un fichero o necesitas una base vectorial? Encárganos el experimento con tus datos y tu volumen real.