Embeddings + pgvector: busca semântica no seu site (caso real)
Um cliente meu tem clínica odontológica. Oferece 34 tipos de procedimentos diferentes. Pacientes chegam dizendo: “Tenho dente de leite que não caiu, o que fazer?” ou “Quero melhorar minha mordida”.
A busca por keyword não funciona. Se o paciente digita “dente errado” no site, não acha o procedimento de “Ortodontia”. Se digita “limpeza de tártaro”, não acha “Raspagem Periodontal” (que é basicamente a mesma coisa).
Resolvi com busca semântica usando embeddings + pgvector. Hoje o paciente digita qualquer coisa e encontra o procedimento certo. Taxa de cliques em resultados: 65% (era 18% com busca por keyword).
Vou mostrar como fiz.
O problema com busca por keyword
Busca por keyword funciona assim:
Você digita: “limpeza dentes” Sistema procura por posts/páginas que contêm as palavras “limpeza” + “dentes” Retorna matches exatos
Problema: sinonímia. “Limpeza” e “Raspagem” são a mesma coisa pro dentista. Mas pro sistema de keyword, são diferentes.
Outro exemplo real:
- Paciente: “Quero dente menos amarelo”
- Keyword search: sem resultados (“amarelo” não está em nenhuma página)
- Semantic search: acha “Clareamento Dentário” (porque entende que é sobre a cor dos dentes)
Com 34 procedimentos, eu perdia ~40% das buscas por sinonímia.
Como embeddings resolvem
Embeddings transformam texto em números. Vetores no espaço de 1536 dimensões (no caso do OpenAI).
A beleza: textos com significado semelhante ficam próximos nesse espaço.
Exemplo:
- “limpeza dentes” vira [0.002, -0.15, 0.98, …]
- “raspagem periodontal” vira [0.005, -0.14, 0.97, …]
- Esses vetores são próximos (mesma significância)
Quando o paciente busca algo, você:
- Converte a busca em embedding
- Compara com embeddings de todos os procedimentos
- Retorna os mais próximos
E tudo fica semântico, não baseado em palavras exatas.
Pipeline completo que construí
1. Preparar os dados
Você começa com uma lista de procedimentos. Cada um com nome + descrição:
id | nome | descricao
1 | Limpeza Profissional | Remove tártaro e placa. Feito a cada 6 meses.
2 | Raspagem Periodontal | Trata inflamação da gengiva. Profunda e sem dor.
3 | Clareamento | Deixa os dentes mais brancos. Seguro e eficaz.
4 | Ortodontia | Alinha os dentes. Corrige a mordida.
...
2. Gerar embeddings (uma vez)
Você não regenera embeddings toda hora. Faz uma vez e salva no banco.
npm install openai
import { OpenAI } from "openai";
import { createClient } from "@supabase/supabase-js";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
async function generateEmbeddings() {
const { data: procedimentos } = await supabase
.from("procedimentos")
.select("id, nome, descricao");
for (const proc of procedimentos) {
const text = `${proc.nome}. ${proc.descricao}`;
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
const embedding = response.data[0].embedding;
await supabase.from("procedimentos").update({ embedding }).eq("id", proc.id);
console.log(`Embedding gerado para ${proc.nome}`);
}
}
generateEmbeddings();
Isso gera um embedding de 1536 números pra cada procedimento e salva no Supabase.
Custo: ~$0,01 pra gerar embeddings de 34 procedimentos. Você só faz isso uma vez (ou quando adiciona novo procedimento).
3. Instalar pgvector no Supabase
PostgreSQL precisa da extensão pgvector pra fazer busca semântica eficiente.
Se você usa Supabase, ele já vem instalado. Se usa PostgreSQL direto, roda:
CREATE EXTENSION IF NOT EXISTS vector;
4. Criar tabela com coluna de vector
CREATE TABLE procedimentos (
id SERIAL PRIMARY KEY,
nome TEXT NOT NULL,
descricao TEXT NOT NULL,
preco DECIMAL,
embedding vector(1536) -- OpenAI embedding size
);
CREATE INDEX ON procedimentos USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Aquele INDEX é importante pra performance. Sem ele, cada busca varre todas as linhas (lento).
5. Buscar por semelhança
Quando o paciente digita algo, você:
- Gera embedding da busca
- Consulta usando cosine distance
- Retorna top 5
async function searchProcedimentos(query: string) {
const queryEmbedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
const embedding = queryEmbedding.data[0].embedding;
const { data } = await supabase.rpc("match_procedimentos", {
query_embedding: embedding,
match_threshold: 0.7,
match_count: 5,
});
return data;
}
E no Supabase, você cria uma função:
CREATE OR REPLACE FUNCTION match_procedimentos (
query_embedding vector(1536),
match_threshold float,
match_count int
) RETURNS TABLE (
id integer,
nome text,
descricao text,
similarity float
) LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
procedimentos.id,
procedimentos.nome,
procedimentos.descricao,
1 - (procedimentos.embedding <=> query_embedding) as similarity
FROM procedimentos
WHERE 1 - (procedimentos.embedding <=> query_embedding) > match_threshold
ORDER BY procedimentos.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
O operador <=> é cosine distance no pgvector.
6. Colocar numa página
import { useState } from "react";
export default function SearchProcedimentos() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
async function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const q = e.target.value;
setQuery(q);
if (q.length < 2) return;
const response = await fetch("/api/search-procedimentos", {
method: "POST",
body: JSON.stringify({ query: q }),
});
const data = await response.json();
setResults(data);
}
return (
<div>
<input
type="text"
placeholder="Buscar procedimento..."
value={query}
onChange={handleSearch}
/>
<ul>
{results.map((r) => (
<li key={r.id}>
<h3>{r.nome}</h3>
<p>{r.descricao}</p>
<small>Relevância: {(r.similarity * 100).toFixed(0)}%</small>
</li>
))}
</ul>
</div>
);
}
Resultado prático
Antes com keyword search:
- Paciente: “quero melhorar minha mordida”
- Resultado: nada (porque “mordida” não está em nenhuma página)
Depois com semantic search:
- Paciente: “quero melhorar minha mordida”
- Resultado: Ortodontia (primeiro resultado, 92% de relevância)
Outro exemplo:
- Paciente: “dente doendo”
- Antes: nada
- Depois: Tratamento de Canal (88% relevância), Emergência Odontológica (85% relevância)
Taxa de cliques subiu de 18% pra 65%. Isso significa que as pessoas acham o que procuram. Menos bounce.
Comparação: keyword vs semantic
| Aspecto | Keyword | Semantic |
|---|---|---|
| ”limpeza dentes” | Acha “Limpeza Profissional” | Acha “Limpeza” + “Raspagem Periodontal" |
| "dor de dente” | Nada | Acha “Tratamento de Canal”, “Emergência" |
| "melhorar aparência” | Nada | Acha “Clareamento”, “Laminado” |
| Speed | <100ms | 50-150ms |
| Custo de manutenção | Zero | ~$0,01 por novo item |
| Precision | 30-40% | 70-80% |
Semantic é mais lenta (1 query pro vector index), mas muito mais precisa.
Custos mensais
Supabase pgvector: $0 (incluído no plano) Embeddings OpenAI: $0,01-0,05 por mês (só quando adiciona procedimentos) Storage: praticamente zero (1536 floats = ~6KB por procedimento)
Total: ~$5-10/mês (se tiver heavy volume). Caso clínica: ~$2/mês.
Quando não usar semantic search
Semantic search é overkill pra alguns casos:
- Catálogo pequeno (<50 itens). Keyword search funciona.
- Busca precisa por ID (tipo: SKU específico). Use keyword.
- Dados muito estruturados (filtro por preço, data). Use SQL puro.
Combine: use semantic pra descoberta, SQL puro pra filtros.
Otimizações que fiz
Threshold inteligente
Comecei com threshold de 0.7 (só retorna 70%+ de relevância). Mas percebi que algumas buscas legítimas ficavam abaixo. Mudei pra dinâmico:
const threshold = query.length > 10 ? 0.6 : 0.75;
Buscas curtas precisam ser mais específicas. Buscas longas podem ser mais generosas.
Cache de buscas populares
Pacientes buscam as mesmas coisas. “limpeza”, “implante”, “clareamento”. Cache essas embeddings:
const cache = new Map();
async function searchWithCache(query: string) {
if (cache.has(query)) {
return cache.get(query);
}
const result = await searchProcedimentos(query);
cache.set(query, result);
return result;
}
Reduz chamadas à OpenAI em 60%.
Hybrid search
Combino semantic + keyword:
SELECT * FROM (
SELECT * FROM procedimentos
WHERE 1 - (embedding <=> query_embedding) > 0.7 -- semantic
UNION
SELECT * FROM procedimentos
WHERE nome ILIKE '%' || query_text || '%' -- keyword
UNION
SELECT * FROM procedimentos
WHERE descricao ILIKE '%' || query_text || '%'
) results
ORDER BY similarity DESC
LIMIT 5;
Best of both worlds. Semantic pra descoberta, keyword como fallback.
Custo e performance real
No meu projeto (clínica com 200 procedimentos):
- Geração de embeddings inicial: 1 chamada OpenAI por procedimento = R$ 0,02 total
- Buscas mensais: 500 buscas, com cache: ~180 chamadas reais = R$ 0,36/mês
- Database: pgvector no Supabase tier gratuito aguenta tranquilo
Ou seja: setup grátis / quase de graça no mês.
Se tivesse 10mil procedimentos, seguiria o mesmo padrão (seria R$ 2-3/mês no cache, que você recupera nas primeiras vendas).
Próximos passos
-
Adicionar feedback: quando paciente clica num resultado, marca como “relevante”. Depois você usa esses dados pra entender quais resultados eram úteis e refinar.
-
Personalização: salva histórico de buscas do paciente e prioriza categorias que ele já buscou. “Última vez você buscou implante, talvez queira saber sobre manutenção”.
-
Analytics: rastreia qual busca tem mais cliques, quais ficam sem resultado
Erros comuns em implementa��o de embeddings
Embeddings com pgVector s�o poderosos mas t�m armadilhas que voc� s� descobre em produ��o. Aqui est�o 3 erros que cometi:
Erro 1: Usar embedding model errado para seu caso de uso
Voc� usa text-embedding-3-small do OpenAI para embeddings de 1.536 dimens�es. Funciona bem pra busca gen�rica. Mas depois quer fazer clustering de cliente (agrupando por padr�o de comportamento), e o modelo n�o � espec�fico o suficiente.
Solu��o: use embedding models especializados. Pra busca sem�ntica gen�rica, 3-small � �timo. Pra legal documents, use legal-embeddings. Pra medical, use medical-embeddings. A diferen�a � 30-40% melhor em accuracy.
Erro 2: N�o normalizar vectors antes de inserir
Voc� insere embeddings diretamente no pgVector sem normalizar. Cosine similarity depois d� resultado ruim. Motivo: embeddings com magnitudes diferentes skewam a busca.
Solu��o: normalize sempre. vector / sqrt(sum(vector^2)) faz isso. Uma linha de c�digo, resultado 2x melhor.
Erro 3: Usar HNSW index quando deveria usar IVFFlat
Voc� tem 100k documentos. Usa HNSW index pra busca r�pida. Mas index cresce muito, queries ficam lentas. Motivo: HNSW � melhor pra datasets pequenos (<50k). Para datasets maiores, IVFFlat � mais eficiente.
Solu��o: use IVFFlat com lists = sqrt(total_rows). Pra 100k documentos, lists = 316. Query � 5-10x mais r�pido.
Checklist: antes de go live com embeddings
- Escolheu embedding model baseado em seu caso (gen�rico vs especializado)
- Normalizou vectors antes de inserir
- Criou �ndice apropriado (HNSW se <50k, IVFFlat se >50k)
- Testou lat�ncia de query sob carga (10 queries simult�neas)
- Backup do pgVector criado
- Monitoramento de storage (embeddings crescem r�pido)
Leia também: Autenticação com Supabase Auth | PostgreSQL Row-Level Security multi-tenant | Supabase + React