Embeddings e Busca Semântica: Como Funciona na Prática
Busca por palavra-chave encontra “Node.js performance”. Busca semântica entende que “otimizar APIs JavaScript” é a mesma coisa. A diferença está em embeddings — representações numéricas do significado semântico de um texto.
O que são embeddings
Um embedding é um vetor de números de alta dimensão (1536 dimensões no text-embedding-3-small da OpenAI) onde textos com significado similar ficam geometricamente próximos no espaço vetorial.
"Node.js performance" → [0.023, -0.145, 0.891, ...] ←── próximos
"otimizar APIs Node" → [0.019, -0.138, 0.887, ...] ←── no espaço
"receita de bolo" → [-0.432, 0.671, -0.203, ...] ←── distanteA proximidade é medida por similaridade cosseno — o ângulo entre dois vetores:
cos(θ) = (A · B) / (|A| × |B|)
Resultado: -1 a 1
1.0 = idênticos
0.0 = não relacionados
-1.0 = opostosGerando embeddings com OpenAI
import OpenAI from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
async function embed(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small', // 1536 dims, barato (~$0.02/1M tokens)
input: text,
encoding_format: 'float',
});
return response.data[0].embedding;
}
// Batch para múltiplos textos (mais eficiente)
async function embedBatch(texts: string[]): Promise<number[][]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: texts,
});
return response.data.map(d => d.embedding);
}pgvector: busca vetorial no PostgreSQL
-- Habilita a extensão
CREATE EXTENSION IF NOT EXISTS vector;
-- Tabela de documentos com campo de embedding
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- dimensão do modelo
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
-- Índice HNSW para busca aproximada (mais rápido, levemente menos preciso)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Ou IVFFlat para datasets maiores
-- CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
-- WITH (lists = 100);Indexando documentos
import { Pool } from 'pg';
const db = new Pool({ connectionString: process.env.DATABASE_URL });
async function indexDocument(doc: {
title: string;
content: string;
metadata?: Record<string, unknown>;
}): Promise<void> {
// Chunking: divide o conteúdo em partes menores
const chunks = chunkText(doc.content, { maxTokens: 500, overlap: 50 });
for (const chunk of chunks) {
const embedding = await embed(`${doc.title}\n\n${chunk}`);
await db.query(
`INSERT INTO documents (title, content, embedding, metadata)
VALUES ($1, $2, $3::vector, $4)`,
[doc.title, chunk, JSON.stringify(embedding), doc.metadata ?? {}]
);
}
}
// Estratégia de chunking simples por tokens
function chunkText(text: string, { maxTokens = 500, overlap = 50 } = {}): string[] {
const sentences = text.match(/[^.!?]+[.!?]+/g) ?? [text];
const chunks: string[] = [];
let current = '';
let tokenCount = 0;
for (const sentence of sentences) {
const sentenceTokens = Math.ceil(sentence.length / 4); // aprox 4 chars/token
if (tokenCount + sentenceTokens > maxTokens && current) {
chunks.push(current.trim());
// Overlap: mantém últimas frases para contexto
const words = current.split(' ');
current = words.slice(-overlap).join(' ') + ' ';
tokenCount = overlap;
}
current += sentence;
tokenCount += sentenceTokens;
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}Buscando por similaridade
async function semanticSearch(
query: string,
{ limit = 5, threshold = 0.7 }: { limit?: number; threshold?: number } = {}
): Promise<SearchResult[]> {
const queryEmbedding = await embed(query);
const results = await db.query<SearchResult>(
`SELECT
id,
title,
content,
metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE 1 - (embedding <=> $1::vector) > $2
ORDER BY embedding <=> $1::vector -- operador de distância cosseno
LIMIT $3`,
[JSON.stringify(queryEmbedding), threshold, limit]
);
return results.rows;
}
// Uso
const results = await semanticSearch('como otimizar performance em Node.js');
// Retorna documentos sobre profiling, bottlenecks, V8, etc.
// mesmo sem as palavras exatas na query
Busca híbrida: semântica + palavras-chave
A busca semântica pura às vezes perde em precisão quando termos técnicos exatos importam. Combine com BM25 (busca full-text):
async function hybridSearch(query: string, limit = 5): Promise<SearchResult[]> {
const queryEmbedding = await embed(query);
const results = await db.query(
`WITH semantic AS (
SELECT id, 1 - (embedding <=> $1::vector) AS sem_score
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
keyword AS (
SELECT id,
ts_rank(to_tsvector('portuguese', content),
plainto_tsquery('portuguese', $2)) AS kw_score
FROM documents
WHERE to_tsvector('portuguese', content) @@ plainto_tsquery('portuguese', $2)
LIMIT 20
)
SELECT
d.id, d.title, d.content,
COALESCE(s.sem_score, 0) * 0.7 + COALESCE(k.kw_score, 0) * 0.3 AS score
FROM documents d
LEFT JOIN semantic s ON s.id = d.id
LEFT JOIN keyword k ON k.id = d.id
WHERE s.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY score DESC
LIMIT $3`,
[JSON.stringify(queryEmbedding), query, limit]
);
return results.rows;
}A ponderação 70% semântica + 30% keyword funciona bem para a maioria dos casos. Ajuste conforme o domínio: para código ou termos técnicos muito específicos, aumente o peso do keyword.
Custos e dimensionamento
| Modelo | Dimensões | Custo/1M tokens | Uso |
|---|---|---|---|
| text-embedding-3-small | 1536 | ~$0.02 | Produção geral |
| text-embedding-3-large | 3072 | ~$0.13 | Alta precisão |
| text-embedding-ada-002 | 1536 | ~$0.10 | Legacy |
Para 100k documentos de ~500 tokens: ~$1 no modelo small. O custo real é o armazenamento e a latência — pgvector com HNSW responde em milissegundos para milhões de vetores.