Embeddings y Vector Databases
Los embeddings son representaciones numéricas de texto (vectores) donde la distancia entre vectores refleja similitud semántica. Las vector databases almacenan y buscan estos vectores eficientemente. Juntos son la base de la búsqueda semántica y RAG.
¿Qué son los embeddings?
Un embedding convierte texto en un vector de números (típicamente 768-3072 dimensiones):
"TypeScript es genial" → [0.023, -0.156, 0.891, ..., 0.045] (1536 dims)
"JavaScript mola" → [0.019, -0.148, 0.887, ..., 0.041] (similares!)
"Receta de paella" → [-0.512, 0.334, 0.021, ..., -0.287] (muy diferente)
Similitud coseno
function cosineSimilarity(a: number[], b: number[]): number {
const dotProduct = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
return dotProduct / (magnitudeA * magnitudeB);
}
// Resultado: -1 (opuestos) a 1 (idénticos)
// > 0.8 = muy similar
// > 0.6 = relacionado
// < 0.4 = no relacionado
Generación de Embeddings
OpenAI Embeddings
import OpenAI from 'openai';
const openai = new OpenAI();
async function getEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small', // 1536 dims, $0.02/1M tokens
input: text,
});
return response.data[0].embedding;
}
// Batch (más eficiente)
async function getEmbeddings(texts: string[]): Promise<number[][]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: texts,
});
return response.data.map(d => d.embedding);
}
Modelos de embeddings
| Modelo | Dimensiones | Costo (1M tokens) | Uso |
|---|---|---|---|
text-embedding-3-small |
1536 | $0.02 | General, buena relación costo/calidad |
text-embedding-3-large |
3072 | $0.13 | Máxima precisión |
Cohere embed-v3 |
1024 | $0.10 | Multilingüe excelente |
Voyage voyage-3 |
1024 | $0.06 | Código y texto técnico |
Vector Databases
pgvector (PostgreSQL)
La opción más simple si ya usas Postgres:
-- Instalar extensión
CREATE EXTENSION vector;
-- Crear tabla
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536), -- dimensiones del modelo
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
-- Crear índice para búsqueda rápida
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Buscar similares
SELECT id, content, metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
pgvector con Prisma
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Insertar documento con embedding
async function insertDocument(content: string, embedding: number[]) {
await prisma.$executeRaw`
INSERT INTO documents (content, embedding)
VALUES (${content}, ${embedding}::vector)
`;
}
// Búsqueda semántica
async function search(queryEmbedding: number[], limit = 5) {
return prisma.$queryRaw`
SELECT id, content, metadata,
1 - (embedding <=> ${queryEmbedding}::vector) AS similarity
FROM documents
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${limit}
`;
}
Pinecone
import { Pinecone } from '@pinecone-database/pinecone';
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pinecone.index('my-app');
// Insertar (upsert)
await index.upsert([
{
id: 'doc-1',
values: embedding, // number[]
metadata: { title: 'Intro a React', source: 'docs', category: 'frontend' },
},
]);
// Buscar
const results = await index.query({
vector: queryEmbedding,
topK: 5,
includeMetadata: true,
filter: { category: { $eq: 'frontend' } }, // Filtros por metadata
});
results.matches.forEach(match => {
console.log(`${match.id}: ${match.score} - ${match.metadata?.title}`);
});
Qdrant
import { QdrantClient } from '@qdrant/js-client-rest';
const qdrant = new QdrantClient({ url: 'http://localhost:6333' });
// Crear colección
await qdrant.createCollection('documents', {
vectors: { size: 1536, distance: 'Cosine' },
});
// Insertar
await qdrant.upsert('documents', {
points: [
{
id: 1,
vector: embedding,
payload: { content: 'Texto del documento', source: 'docs' },
},
],
});
// Buscar
const results = await qdrant.search('documents', {
vector: queryEmbedding,
limit: 5,
filter: {
must: [{ key: 'source', match: { value: 'docs' } }],
},
});
Comparación de Vector DBs
| Base de datos | Tipo | Ventaja principal | Mejor para |
|---|---|---|---|
| pgvector | Extensión PostgreSQL | Sin infra adicional | Apps con Postgres existente |
| Pinecone | SaaS managed | Cero mantenimiento, escalable | Producción sin ops |
| Qdrant | Self-hosted / Cloud | Filtros avanzados, rápido | Control total |
| ChromaDB | In-process (Python) | Simple para prototipos | Desarrollo local |
| Weaviate | Self-hosted / Cloud | Búsqueda híbrida nativa | Apps multimodales |
Pipeline completo: Texto → Embedding → Búsqueda
class SemanticSearch {
private openai: OpenAI;
constructor() {
this.openai = new OpenAI();
}
async embed(text: string): Promise<number[]> {
const response = await this.openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding;
}
async indexDocument(content: string, metadata: Record<string, any>) {
const embedding = await this.embed(content);
await db.query(
`INSERT INTO documents (content, embedding, metadata)
VALUES ($1, $2::vector, $3)`,
[content, JSON.stringify(embedding), JSON.stringify(metadata)]
);
}
async search(query: string, limit = 5) {
const queryEmbedding = await this.embed(query);
const results = await db.query(
`SELECT content, metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[JSON.stringify(queryEmbedding), limit]
);
return results.rows.filter(r => r.similarity > 0.5);
}
}
// Uso
const search = new SemanticSearch();
// Indexar
await search.indexDocument(
'React hooks permiten usar estado en componentes funcionales',
{ source: 'docs', topic: 'react' }
);
// Buscar
const results = await search.search('¿cómo manejo estado en React?');
// Encuentra el documento aunque las palabras sean diferentes!
Optimizaciones
1. Batch embeddings
// ❌ Lento: una llamada por documento
for (const doc of documents) {
const embedding = await getEmbedding(doc.content);
}
// ✅ Rápido: batch de hasta 2048 textos
const embeddings = await getEmbeddings(documents.map(d => d.content));
2. Caché de embeddings
import { createHash } from 'crypto';
const embeddingCache = new Map<string, number[]>();
async function getCachedEmbedding(text: string): Promise<number[]> {
const hash = createHash('md5').update(text).digest('hex');
if (embeddingCache.has(hash)) {
return embeddingCache.get(hash)!;
}
const embedding = await getEmbedding(text);
embeddingCache.set(hash, embedding);
return embedding;
}
3. Reducción de dimensiones
// text-embedding-3-small soporta dimensiones reducidas
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
dimensions: 512, // Reduce de 1536 a 512 (más rápido, menos storage)
});