La búsqueda por palabras clave compara cadenas. La búsqueda semántica compara significado: «Olvidé mi inicio de sesión» encuentra el artículo titulado «Restablecer tu contraseña» aunque no compartan ni una palabra. El mecanismo son los embeddings: un modelo convierte cada fragmento de texto en un vector de unos miles de números, y los textos que significan cosas parecidas quedan cerca unos de otros en ese espacio. Genera esos vectores una vez, guárdalos y podrás ordenar cualquier cosa por relevancia: la mitad de recuperación de todo sistema RAG.
Brievio expone /v1/embeddings como un endpoint compatible con OpenAI listo para usar, así que la llamada del SDK es idéntica a la que ya escribes — solo cambia el base_url. Los embeddings son baratos, pagas conteos de tokens honestos y las llamadas fallidas 4xx/5xx no cuestan nada. Este artículo es el camino práctico: generar vectores, puntuarlos con similitud del coseno, almacenarlos y consultarlos con pgvector, y conectar el resultado a una canalización de recuperación — con las advertencias que de verdad muerden.
Generar embeddings
Una sola llamada. Pasa una lista de cadenas y recibe una lista de vectores en el mismo orden. Agrupa siempre — unos cientos de entradas por petición cuesta lo mismo por token que de una en una, pero te ahorra cientos de viajes de ida y vuelta:
# Genera embeddings con el SDK de OpenAI — misma llamada, distinto base_url.
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
EMBED_MODEL = "text-embedding-3-large" # elige un modelo y mantente con él
def embed(texts: list[str]) -> list[list[float]]:
# Agrupa hasta unos cientos de entradas por llamada — muchos menos viajes de ida
# y vuelta, mismo precio por token. La respuesta conserva el orden de entrada.
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [row.embedding for row in resp.data]
vecs = embed(["¿Cómo restablezco mi contraseña?", "¿Dónde está mi factura?"])
print(len(vecs), "x", len(vecs[0])) # 2 x 3072 (dimensión según el modelo)
# el uso se reporta con honestidad — los embeddings se miden por tokens como cualquier otra llamada
print(resp.usage.prompt_tokens, "tokens facturados")La longitud del vector (su dimensión) la fija el modelo — habitualmente 768, 1536 o 3072. Las dimensiones más altas capturan algo más de matiz, pero cuestan más de almacenar y comparar. La regla más importante de todas: elige un modelo de embeddings y no lo mezcles nunca. Un vector de un modelo y un vector de otro viven en espacios distintos e incompatibles — compararlos produce ruido. Consulta el catálogo de modelos en vivo para ver los modelos de embeddings disponibles y sus dimensiones; el precio por modelo está en la página de precios.
Puntuar con similitud del coseno
Para encontrar las coincidencias más cercanas, comparas el vector de la consulta contra tus vectores almacenados. La métrica estándar es la similitud del coseno: mide el ángulo entre dos vectores e ignora su longitud, de modo que le importa la dirección (el significado) y no la magnitud. 1.0 significa la misma dirección; 0 significa sin relación:
# Búsqueda semántica = embebe la consulta, puntúala contra cada vector almacenado
# y devuelve el más cercano. La función de puntuación es la similitud del coseno.
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
# 1.0 = misma dirección, 0 = sin relación, -1 = opuesta.
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
# Muchos modelos de embeddings ya devuelven vectores de longitud unitaria. Cuando lo hacen,
# la similitud del coseno se reduce a un simple producto escalar — más barato a escala:
def cosine_unit(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b)
query = np.array(embed(["Olvidé mi inicio de sesión"])[0])
corpus = np.array(embed(documents)) # (N, dim)
scores = corpus @ query / (
np.linalg.norm(corpus, axis=1) * np.linalg.norm(query)
)
top = scores.argsort()[::-1][:5] # índices de las 5 mejores coincidencias
for i in top:
print(round(float(scores[i]), 3), documents[i][:60])Dos notas prácticas. Primera, muchos modelos de embeddings ya devuelven vectores de longitud unitaria — cuando lo hacen, la similitud del coseno es solo un producto escalar, que es lo que hace que la búsqueda por fuerza bruta sobre decenas de miles de vectores sea lo bastante rápida como para prescindir por completo de una base de datos. Segunda, el bucle de fuerza bruta va bien hasta unas decenas de miles de vectores como mucho. A partir de ahí, recorrer cada vector en cada consulta se vuelve lento, y querrás un almacén de vectores de verdad con un índice.
Almacenar y consultar con pgvector
Si tus datos ya viven en Postgres, no necesitas una base de datos de vectores aparte — la extensión pgvector añade un tipo de columna vector y operadores de vecino más cercano. Embebes cada fragmento una vez, guardas el vector junto a su texto y dejas que Postgres haga la búsqueda:
-- pgvector convierte Postgres en un almacén de vectores. Actívalo una vez y luego
-- guarda el embedding de cada fragmento junto a su texto y sus metadatos.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
content text NOT NULL,
embedding vector(3072) -- debe coincidir con la dimensión de tu modelo
);
-- Índice de vecino más cercano aproximado. <=> es la distancia del coseno en pgvector
-- (0 = más cercano). Construye el índice DESPUÉS de la carga masiva, no antes.
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops);
-- Recuperación: pasa el embedding de la consulta como parámetro ($1) y toma las
-- filas más cercanas. Ordena por distancia ascendente = más similar primero.
SELECT id, doc_id, content, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE doc_id = ANY($2) -- prefiltro opcional por metadatos
ORDER BY embedding <=> $1
LIMIT 5;El operador <=> es la distancia del coseno (0 = idéntico), así que 1 - (embedding <=> $1) te devuelve una puntuación de similitud. El índice hnsw hace la búsqueda aproximada pero rápida — para la mayoría de cargas de recuperación el diminuto sacrificio en recall es invisible, y lo construyes después de la carga masiva, nunca antes. La cláusula WHERE opcional es el superpoder silencioso: filtra por inquilino, documento, idioma o fecha antes de la búsqueda vectorial para no recuperar nunca un vecino que el usuario no tiene permiso de ver.
Conectarlo a RAG
La generación aumentada por recuperación son dos pasos cosidos: búsqueda semántica para encontrar contexto relevante y, después, un modelo de generación que responde usando ese contexto. La parte de recuperación es todo lo anterior. La parte de generación es una compleción de chat normal — y aquí es donde emparejas un modelo de embeddings barato con un modelo de razonamiento potente:
- Fragmenta y luego embebe. Divide los documentos en pasajes de unos pocos cientos de tokens con un poco de solapamiento, embebe cada uno y guárdalos. La fragmentación es la palanca en la que la mayoría de equipos invierten de menos: fragmentos demasiado grandes entierran la respuesta en ruido; demasiado pequeños pierden el contexto que los hace significativos. Ajústala con tus propios datos.
- Recupera en el momento de la consulta. Embebe la pregunta del usuario con el mismo modelo, extrae los 3–8 fragmentos principales y pega su texto en el prompt como contexto.
- Genera la respuesta. Envía ese contexto más la pregunta a un modelo capaz — Claude o Gemini a través del mismo endpoint de Brievio — e indícale que responda solo a partir del contexto facilitado y que cite qué fragmento usó.
La economía funciona porque las dos mitades tienen precios radicalmente distintos. Embeber todo tu corpus es un trabajo por lotes puntual y de bajo coste; reembeber solo ocurre cuando el contenido cambia. El coste por consulta lo domina el paso de generación, no la recuperación — y por eso mismo las técnicas de control de costes de nuestro manual de optimización de costes de APIs de IA (cachear el prompt de las instrucciones estáticas, limitar el output, elegir el modelo adecuado por tarea) mueven mucho más la aguja que cualquier cosa del lado de los embeddings.
Las advertencias que de verdad muerden
- Los vectores no son intercambiables entre modelos. Cambia de modelo de embeddings y tendrás que reembeber el corpus entero — los vectores de consulta y los almacenados tienen que venir del mismo modelo, o las puntuaciones no significan nada. Trata la elección del modelo como una decisión de esquema.
- La dimensión es un compromiso entre almacenamiento y velocidad. Un vector de 3072 dimensiones ocupa cuatro veces los bytes de uno de 768 y es más lento de comparar. Más grande no es automáticamente mejor para tu tarea — mide el recall sobre tus propias consultas antes de pagar por el modelo más grande.
- La calidad de la fragmentación supera a la del modelo. La mayoría de respuestas RAG malas se remontan a fragmentos malos, no a un modelo de embeddings débil. Respeta la estructura del documento — divide por encabezados y párrafos, no por un recuento de caracteres a ciegas.
- La búsqueda semántica puede pasar por alto términos exactos. Los SKU de productos, los códigos de error y los nombres a veces coinciden mejor con la búsqueda por palabras clave. En la práctica, los sistemas más sólidos son híbridos: combinan la similitud vectorial con una puntuación clásica de texto completo o BM25.
- Los embeddings también tienen un límite de contexto. El texto que supera el límite de entrada del modelo se trunca en silencio — tu «embedding» entonces solo representa la primera parte de un fragmento demasiado largo. Mantén los fragmentos cómodamente por debajo del límite.
La conclusión concreta
La búsqueda semántica son cuatro piezas móviles: un modelo de embeddings, una métrica de similitud, un almacén y una estrategia de fragmentación. Elige un modelo de embeddings y comprométete con él; usa la similitud del coseno; empieza con fuerza bruta en NumPy y gradúate al índice HNSW de pgvector cuando tu corpus se quede grande para un solo recorrido; y gasta tu presupuesto de ajuste en la fragmentación, porque es ahí donde se gana o se pierde la calidad de la recuperación. A través de Brievio, la llamada de embeddings es el SDK de OpenAI que ya conoces con una sola línea cambiada, medida con conteos de tokens honestos, y se sitúa junto a los modelos Claude y Gemini que usarás para el paso de generación — una clave, un base_url, toda la canalización RAG. La referencia del endpoint y un inicio rápido ejecutable están en la documentación.