Probablemente no necesite una base de datos de vectores para su RAG, todavía

, gracias a la generación aumentada de recuperación (RAG), las bases de datos vectoriales están recibiendo mucha atención en el mundo de la IA.

Mucha gente dice que necesita herramientas como Pinecone, Weaviate, Milvus o Qdrant para construir un sistema RAG y administrar sus incrustaciones. Si trabaja en aplicaciones empresariales con cientos de millones de vectores, herramientas como estas son esenciales. Le permiten realizar operaciones CRUD, filtrar por metadatos y utilizar indexación basada en disco que va más allá de la memoria de su computadora.

Pero para la mayoría de las herramientas internas, robots de documentación o agentes MVP, agregar una base de datos vectorial dedicada puede resultar excesivo. Aumenta la complejidad, los retrasos en la red, agrega costos de serialización y complica la administración de las cosas.

La verdad es que la “búsqueda vectorial” (es decir, la parte de recuperación de RAG) es solo una multiplicación de matrices. Y Python ya cuenta con algunas de las mejores herramientas del mundo para eso.

En este artículo, mostraremos cómo construir un componente de recuperación listo para producción de una canalización RAG para volúmenes de datos pequeños y medianos usando solo NumPy y SciKit-Learn. Verás que es posible buscar millones de cadenas de texto en milisegundos, todo en la memoria y sin dependencias externas.

Comprender la recuperación como matemática matricial

Normalmente, RAG implica cuatro pasos principales:

Incrustar: Convierta el texto de sus datos de origen en vectores (listas de números de punto flotante) Almacenar: Guarde esos vectores en una base de datos Recuperar: Encuentre vectores que estén matemáticamente "cercanos" al vector de consulta. Generar: envíe el texto correspondiente a un LLM y obtenga su respuesta final.

Los pasos 1 y 4 se basan en modelos de lenguaje grandes. Los pasos 2 y 3 son dominio de Vector DB. Nos concentraremos en las partes 2 y 3 y en cómo evitamos por completo el uso de bases de datos vectoriales.

Pero cuando buscamos en nuestra base de datos de vectores, ¿qué es realmente “cercanía”? Por lo general, es similitud de coseno. Si sus dos vectores están normalizados para tener una magnitud de 1, entonces la similitud del coseno es solo el producto escalar de los dos.

Si tiene un vector de consulta unidimensional de tamaño N, Q(1xN) y una base de datos de vectores de documentos de tamaño M por N, D(MxN), encontrar las mejores coincidencias no es una consulta de base de datos; es una operación de multiplicación de matrices, el producto escalar de D con la transpuesta de Q.

Puntuaciones = DQ^T

NumPy está diseñado para realizar este tipo de operaciones de manera eficiente, utilizando rutinas que aprovechan las funciones modernas de la CPU, como la vectorización.

La implementación

Crearemos una clase llamada SimpleVectorStore para manejar la ingesta, indexación y recuperación. Nuestros datos de entrada consistirán en uno o más archivos que contienen el texto que queremos buscar. El uso de Sentence Transformers para incrustaciones locales hará que todo funcione sin conexión.

Requisitos previos

Configure un nuevo entorno de desarrollo, instale las bibliotecas necesarias e inicie un cuaderno Jupyter.

Escriba los siguientes comandos en un shell de comandos. Estoy usando UV como administrador de paquetes; cámbielo para adaptarse a cualquier herramienta que esté utilizando.

$ uv init ragdb $ cd ragdb $ uv venv ragdb $ fuente ragdb/bin/activate $ uv pip install numpy scikit-learn transformadores de oraciones jupyter $ jupyter notebook

La tienda de vectores en memoria

No necesitamos un servidor complicado. Todo lo que necesitamos es una función para cargar nuestros datos de texto desde los archivos de entrada y dividirlos en fragmentos del tamaño de un byte, y una clase con dos listas: una para los fragmentos de texto sin formato y otra para la matriz de incrustación. Aquí está el código.

importar numpy como np importar sistema operativo desde Sentence_transformers importar SentenceTransformer desde sklearn.metrics.pairwise importar coseno_similaridad desde escribir importar Lista, Dict, Cualquiera desde pathlib importar Clase de ruta SimpleVectorStore: def __init__(self, model_name: str = 'all-MiniLM-L6-v2'): print(f"Cargando modelo de incrustación: {model_name}…") self.encoder = SentenceTransformer(nombre_modelo) self.documentos =[]# Almacena el texto sin formato y los metadatos self.embeddings = Ninguno # Se convertirá en una matriz numpy def add_documents(self, docs: List[Dict[str, Any]]): """ Ingiere documentos. formato docs: [{'text': '…', 'metadata': {…}}, …] """ texts = [d['text'] for d in docs] # 1. Generar incrustaciones print(f"Incrustar documentos {len(texts)}…") new_embeddings = self.encoder.encode(texts) # 2. Normalizar incrustaciones # (Optimización crítica: permite que el producto escalar se aproxime a la similitud del coseno) norma = np.linalg.norm(new_embeddings, axis=1, keepdims=True) new_embeddings = new_embeddings / norma # 3. Actualizar almacenamiento si self.embeddings es Ninguno: self.embeddings = new_embeddings else: self.embeddings = np.vstack([self.embeddings, new_embeddings]) self.documents.extend(docs) print(f"La tienda ahora contiene {len(self.documents)} documentos.") def search(self, query: str, k: int = 5): """ Recupera el top-k documentos más similares """ si self.embeddings es Ninguno o len(self.documents) == 0: print("Advertencia: el almacén de vectores está vacío. No hay documentos para buscar.") return.[]# 1. Incrustar y normalizar consulta query_vec = self.encoder.encode([query]) norm = np.linalg.norm(query_vec, axis=1, keepdims=True) query_vec = query_vec / norm # 2. Búsqueda vectorizada (multiplicación de matrices) # Forma del resultado: (1, N_docs) puntuaciones = np.dot(self.embeddings, query_vec.T).flatten() # 3. Obtener índices Top-K # argsort ordena de forma ascendente, por lo que tomamos el último k y los invertimos # Aseguramos que k no exceda el número de documentos k = min(k, len(self.documents)) top_k_indices = np.argsort(scores)[-k:][::-1] results =[]para idx en top_k_indices: results.append({ "score": float(scores[idx]), "text": self.documents[idx]['text'], "metadata": self.documents[idx].get('metadata', {}) }) devuelve resultados def load_from_directory(directory_path: str, chunk_size: int = 1000, superposición: int = 200): """ Lee archivos .txt y los divide en partes superpuestas. """ docs =[]# Use pathlib para un manejo y resolución de rutas sólidos path = Path(directory_path).resolve() if not path.exists(): print(f"Error: Directorio '{path}' no encontrado.") print(f"Directorio de trabajo actual: {os.getcwd()}") return docs print(f"Cargando documentos desde: {path}") for file_path in path.glob("*.txt"): try: with open(file_path, "r", encoding="utf-8") as f: text = f.read() # Fragmentos simples de ventana deslizante # Repetimos el texto con un tamaño de paso menor que el tamaño del fragmento # para crear superposición (preservando el contexto entre fragmentos). paso = tamaño_fragmento – superposición para i en rango(0, len(texto), paso): fragmento = texto[i: i + tamaño_pedazo] # Omitir fragmentos que son demasiado pequeños (por ejemplo, espacios en blanco sobrantes) si len(fragmento) < 50: continuar docs.append({ "text": fragmento, "metadata": { "fuente": ruta_archivo.nombre, "index_fragmento": i } }) excepto excepción como e: print(f"Advertencia: No se pudo leer el archivo {file_path.name}: {e}") print(f"Se cargaron correctamente {len(docs)} fragmentos de {len(list(path.glob('*.txt')))} archivos.") return docs

El modelo de incrustación utilizado.

El modelo totalmente MiniLM-L6-v2 utilizado en el código proviene de la biblioteca Sentence Transformers. Esto fue elegido porque,

Es rápido y liviano. Produce vectores de 384 dimensiones que utilizan menos memoria que los modelos más grandes. Funciona bien en una amplia variedad de tareas en inglés sin necesidad de ajustes especializados.

Este modelo es sólo una sugerencia. Puede utilizar cualquier modelo de incrustación que desee si tiene un favorito en particular.

¿Por qué normalizar?

Es posible que observe los pasos de normalización en el código. Lo mencionamos antes, pero para que quede claro, dados dos vectores X e Y, la similitud del coseno se define como

Similitud = (X · Y) / (||X|| * ||Y||)

Dónde:

X · Y es el producto escalar de los vectores X e Y ||X|| es la magnitud (longitud) del vector X ||Y|| es la magnitud del vector Y

Dado que la división requiere cálculos adicionales, si todos nuestros vectores tienen magnitud unitaria, el denominador es 1, por lo que la fórmula se reduce al producto escalar de X e Y, lo que hace que la búsqueda sea mucho más rápida.

Probar el rendimiento

Lo primero que debemos hacer es obtener algunos datos de entrada para trabajar. Puede utilizar cualquier archivo de texto de entrada para esto. Para experimentos RAG anteriores, utilicé un libro que descargué del Proyecto Gutenberg. El fascinante consistentemente:

“Enfermedades del ganado vacuno, ovino, caprino y porcino por Jno. AW Dollar & G. Moussu”

Tenga en cuenta que puede ver la página Permisos, licencias y otras solicitudes comunes del Proyecto Gutenberg mediante el siguiente enlace.

https://www.gutenberg.org/policy/permission.html

Pero, en resumen, la gran mayoría de los libros electrónicos del Proyecto Gutenberg son de dominio público en Estados Unidos y otras partes del mundo. Esto significa que nadie puede conceder o negar permiso para hacer con este artículo lo que quiera.

“…como quieras” incluye cualquier uso comercial, republicación en cualquier formato, realización de trabajos o interpretaciones derivadas.

Descargué el texto del libro del sitio web del Proyecto Gutenberg a mi PC local usando este enlace,

https://www.gutenberg.org/ebooks/73019.txt.utf-8

Este libro contenía aproximadamente 36.000 líneas de texto. Consultar el libro requiere sólo seis líneas de código. Para mi pregunta de ejemplo, la línea 2315 del libro analiza una enfermedad llamada CONDILOMA. Aquí está el extracto,

INFLAMACIÓN DEL ESPACIO INTERDIGITAL.

(CONDILOMAS.)

Los condilomas son el resultado de una inflamación crónica de la piel que cubre la
ligamento interdigital. Cualquier daño a esta región que cause incluso
El daño superficial puede provocar una inflamación crónica de la piel y
hipertrofia de las papilas, la primera etapa en la producción de
condilomas.

Las lesiones producidas por cordones se deslizaron en el espacio interdigital para el
El propósito de levantar los pies al herrar los bueyes de trabajo también es fructífero.
causas.

Entonces eso es lo que preguntaremos: "¿Qué son los condilomas?" Tenga en cuenta que no obtendremos una respuesta adecuada ya que no estamos ingresando el resultado de nuestra búsqueda en un LLM, pero deberíamos ver que nuestra búsqueda devuelve un fragmento de texto que le daría al LLM toda la información necesaria para formular una respuesta si lo hubiéramos hecho.

%%time # 1. Inicializar store = SimpleVectorStore() # 2. Cargar documentos real_docs = load_from_directory("/mnt/d/book") # 3. Agregar a Store si real_docs: store.add_documents(real_docs) # 4. Resultados de búsqueda = store.search("¿Qué son los condilomas?", k=1) resultados

Y aquí está el resultado.

Cargando modelo de incrustación: all-MiniLM-L6-v2… Cargando documentos desde: /mnt/d/book Se cargaron con éxito 2205 fragmentos de 1 archivo. Incrustando 2205 documentos… La tienda ahora contiene 2205 documentos. Tiempos de CPU: usuario 3,27 s, sistema: 377 ms, total: 3,65 s Tiempo de pared: 3,82 s [{'score': 0.44883957505226135, 'text': 'dos últimasnfalanges, siendo esta última operación más fácil que la primera, ynproporcionando solapas de forma más regular y mejor adaptadas paranla producción de un producto satisfactorio muñón.nnn INFLAMACIÓN DEL ESPACIO INTERDIGITAL.nn(CONDILOMAS.)nn Los condilomas resultan de la inflamación crónica de la piel que cubre elnligamento interdigital. Cualquier lesión en esta región que cause inclusondaño superficial puede provocar inflamación crónica de la piel ynhipertrofia de las papilas, la primera etapa en la producción dencondilomas.nnLas lesiones producidas por cordones introducidos en el espacio interdigital con elnpropósito de levantar los pies cuando se herra a los bueyes de trabajo también sonncausas fructíferas.nnLa inflamación del espacio interdigital también es una complicación común denaftas erupciones alrededor de las garras y en el espacio entre ellas.nEl contacto continuo con la basura, el estiércol y la orina favorecen la infección denheridas superficiales o profundas y, al provocar una granulación exuberante, conducen anhipertrofia de la capa papilar de ', 'metadata': {'source': 'cattle_disease.txt', 'chunk_index': 122400}}]

Menos de 4 segundos para leer, fragmentar, almacenar y consultar correctamente un documento de texto de 36000 líneas es bastante bueno.

SciKit-Learn: el camino de actualización

NumPy funciona bien para búsquedas de fuerza bruta. Pero, ¿qué pasa si tienes docenas o cientos de documentos y la fuerza bruta es demasiado lenta? Antes de cambiar a una base de datos vectorial, puede probar NearestNeighbors de SciKit-Learn. Utiliza estructuras basadas en árboles como KD-Tree y Ball-Tree para acelerar las búsquedas a O(log N) en lugar de O(N).

Para probar esto, descargué muchos otros libros de Gutenberg, que incluyen:

Un cuento de Navidad de Charles Dickens La vida y aventuras de Papá Noel de L. Frank Baum Guerra y paz de Tolstoi Adiós a las armas de Hemingway

En total, estos libros contienen alrededor de 120.000 líneas de texto. Copié y pegué los cinco archivos de libros de entrada diez veces, lo que resultó en cincuenta archivos y 1,2 millones de líneas de texto. Son alrededor de 12 millones de palabras, suponiendo un promedio de 10 palabras por línea. Para proporcionar algo de contexto, este artículo contiene aproximadamente 2800 palabras, por lo que el volumen de datos con el que estamos probando equivale a más de 4000 veces el volumen de este texto.

$ dir achristmascarol – Copiar (2).txt enfermedad_ganado – Copiar (9).txt santa – Copiar (6).txt achristmascarol – Copiar (3).txt enfermedad_ganado – Copiar.txt santa – Copiar (7).txt achristmascarol – Copiar (4).txt enfermedad_ganado.txt santa – Copiar (8).txt achristmascarol – Copiar (5).txt farewelltoarms – Copiar (2).txt santa – Copiar (9).txt achristmascarol – Copiar (6).txt farewelltoarms – Copiar (3).txt santa – Copy.txt achristmascarol – Copiar (7).txt farewelltoarms – Copiar (4).txt santa.txt achristmascarol – Copiar (8).txt farewelltoarms – Copiar (5).txt warandpeace – Copy (2).txt achristmascarol – Copiar (9).txt farewelltoarms – Copiar (6).txt warandpeace – Copiar (3).txt achristmascarol – Copiar.txt adiós a las armas – Copiar (7).txt guerraypaz – Copiar (4).txt achristmascarol.txt adiós a las armas – Copiar (8).txt guerraypaz – Copiar (5).txt enfermedad_del_ganado – Copiar (2).txt adiós a las armas – Copia (9).txt guerraypaz – Copiar (6).txt ganado_enfermedad – Copiar (3).txt farewelltoarms – Copiar.txt guerraypaz – Copiar (7).txt ganado_enfermedad – Copiar (4).txt farewelltoarms.txt guerraypaz – Copiar (8).txt ganado_enfermedad – Copiar (5).txt santa – Copiar (2).txt guerra y paz – Copiar (9).txt enfermedad_ganado – Copiar (6).txt santa – Copiar (3).txt guerra y paz – Copiar.txt enfermedad_ganado – Copiar (7).txt santa – Copiar (4).txt guerra y paz.txt enfermedad_ganado – Copiar (8).txt santa – Copiar (5).txtDigamos que estamos listos.

Digamos que finalmente buscábamos una respuesta a la siguiente pregunta,

¿Por quién, después de las vacaciones de Navidad, Nicolás le contó su amor a su madre?

Por si no lo sabías, esto proviene de la novela Guerra y Paz.

Veamos cómo funciona nuestra nueva búsqueda frente a esta gran cantidad de información.

Aquí está el código que usa SciKit-Learn.

En primer lugar, tenemos una nueva clase que implementa el algoritmo Vecino más cercano de SciKit-Learn.

from sklearn.neighbors import NearestNeighbors class ScikitVectorStore(SimpleVectorStore): def __init__(self, model_name='all-MiniLM-L6-v2'): super().__init__(model_name) # La fuerza bruta suele ser más rápida que los árboles para datos de alta dimensión # a menos que N sea muy grande, pero 'ball_tree' puede ayudar en casos específicos. self.knn = Vecinos más cercanos(n_vecinos=5, métrica='coseno', algoritmo='bruto') self.is_fit = False def build_index(self): print("Construyendo el índice Scikit-Learn…") self.knn.fit(self.embeddings) self.is_fit = True def search(self, query: str, k: int = 5): si no self.is_fit: self.build_index() query_vec = self.encoder.encode([query]) # Nota: Scikit-learn maneja la normalización internamente para la métrica de coseno # si está configurada, pero lo explícito es mejor. distancias, índices = self.knn.kneighbors(query_vec, n_neighbors=k) resultados =[]para i en rango(k): idx = índices[0][i] # Convertir la distancia nuevamente a puntaje de similitud (1 – dist) puntaje = 1 – distancias[0][i] results.append({ "score": score, "text": self.documents[idx]['text'] }) devuelve resultados

Y nuestro código de búsqueda es tan simple como el de la versión NumPy.

%%time # 1. Inicializar tienda = ScikitVectorStore() # 2. Cargar documentos real_docs = load_from_directory("/mnt/d/book") # 3. Agregar a la tienda si real_docs: store.add_documents(real_docs) # 4. Resultados de búsqueda = store.search("¿Por quién, después de las vacaciones de Navidad, Nicholas le contó a su madre su amor?", k=1) resultados

Y nuestra producción.

Cargando modelo de incrustación: all-MiniLM-L6-v2… Cargando documentos desde: /mnt/d/book Se cargaron con éxito 73060 fragmentos de 50 archivos. Incrustando 73060 documentos… La tienda ahora contiene 73060 documentos. Construyendo el índice Scikit-Learn… Tiempos de CPU: usuario 1 min 46 s, sistema: 18,3 s, total: 2 min 4 s Tiempo de pared: 1 min 13 s [{'score': 0.6972659826278687, 'text': 'nCAPÍTULO XIIInnPoco después de las vacaciones de Navidad, Nicholas le contó a su madre su amornpor Sónya y su firme determinación casarse con ella. La condesa, que hacía tiempo que se había dado cuenta de lo que pasaba entre ellos y esperaba esta declaración, lo escuchó en silencio y luego le dijo a su hijo que podía casarse con quien quisiera, pero que ni ella ni su padre darían su bendición a tal matrimonio. Nicolás, por primera vez, sintió que su madre estaba disgustada con él y que, a pesar de su amor por él, no cedería. Fríamente, sin mirar a su hijo, mandó llamar a su marido y, cuando éste llegó, intentó breve y fríamente informarle de los hechos, en presencia de su hijo, pero no pudiendo contenerse rompió en lágrimas de irritación y abandonó la habitación. El viejo conde comenzó irresolutamente a amonestar a Nicolás y a rogarle que abandonara su propósito. Nicolás respondió que no podía faltar a su palabra, y sunpadre, suspirando y evidentemente desconcertado, muy pronto guardó silencio ', 'metadata': {'source': 'warandpeace – Copy (6).txt', 'chunk_index': 1396000}}]

Casi todo el 1m 13s que tomó realizar el procesamiento anterior se dedicó a cargar y fragmentar nuestros datos de entrada. La parte de búsqueda real, cuando la ejecuté por separado, ¡tomó menos de una décima de segundo!

No está nada mal.

Resumen

No estoy diciendo que las bases de datos vectoriales no sean necesarias. Resuelven problemas específicos que NumPy y SciKit-Learn no manejan. Debe migrar desde algo como SimpleVectorStore o ScikitVectorStore a Weaviate/Pinecone/pgvector, etc., cuando se aplique cualquiera de las siguientes condiciones.

Persistencia: necesita datos para sobrevivir al reinicio del servidor sin tener que reconstruir el índice a partir de los archivos fuente cada vez. Aunque np.save o pickling funcionan para una simple persistencia. La ingeniería siempre implica compensaciones. El uso de una base de datos vectorial agrega complejidad a su configuración a cambio de una escalabilidad que quizás no necesite en este momento. Si comienza con una configuración RAG más sencilla usando NumPy y/o SciKit-Learn para el proceso de recuperación, obtendrá:

La RAM es el cuello de botella: su matriz de incrustación excede la memoria de su servidor. Nota: 1 millón de vectores de 384 dimensiones [float32] equivalen a solo ~1,5 GB de RAM, por lo que cabe mucha memoria.

Frecuencia CRUD: debe actualizar o eliminar vectores individuales constantemente durante la lectura. Las matrices NumPy, por ejemplo, son inmutables y para agregarlas es necesario copiar toda la matriz, lo cual es lento.

Filtrado de metadatos: necesita consultas complejas como "Buscar vectores cerca de X donde user_id=10 Y fecha> 2023". Hacer esto en NumPy requiere máscaras booleanas que pueden resultar confusas.

La ingeniería siempre implica compensaciones. El uso de una base de datos vectorial agrega complejidad a su configuración a cambio de una escalabilidad que quizás no necesite en este momento. Si comienza con una configuración RAG más sencilla usando NumPy y/o SciKit-Learn para el proceso de recuperación, obtendrá:

Menor latencia. Sin saltos de red. Costos más bajos. Sin suscripciones SaaS ni instancias adicionales. Sencillez. Es sólo un script de Python.

Del mismo modo que no necesitas un coche deportivo para ir al supermercado. En muchos casos, NumPy o SciKit-Learn pueden ser toda la búsqueda RAG que necesita.