Ir para o conteúdo
Nextjs

Busca Semântica com Embeddings em Next.js

Marcos Soares
Atualizado em 
12 minutos de leitura
Ilustracao 3D de prisma de vidro com nuvem de pontos vetoriais iluminados por luz ciano representando busca semantica
Ouça este artigo
0:00Busca Semântica com Embeddings em Next.js--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema que busca textual não resolve

Seu usuário digita "como lidar com erro no deploy" e a busca por texto retorna zero resultados porque o conteúdo usa "falha durante publicação". Busca textual compara strings. Busca semântica compara significado. A diferença prática: um LIKE '%deploy%' nunca encontra um artigo que fala sobre "publicação em produção", mas um vetor de embedding sim, porque ambos ocupam regiões próximas no espaço vetorial.

Embeddings são representações numéricas de texto em espaços de alta dimensionalidade (1536 dimensões no caso do text-embedding-3-small da OpenAI). Textos com significado parecido ficam próximos nesse espaço. A operação de busca se resume a: transformar a query do usuário em vetor, calcular distância contra vetores armazenados, retornar os mais próximos.

Este post implementa esse fluxo completo em Next.js com App Router, PostgreSQL + pgvector e a API de embeddings da OpenAI. Ao final, você terá uma busca semântica funcional em Route Handlers, com indexação de conteúdo e consulta por similaridade.

Escolhendo onde armazenar vetores

Antes de escrever código, a decisão de storage define custo, latência e complexidade operacional.

Critériopgvector (PostgreSQL)PineconeQdrant (self-hosted)
Custo até 100k vetoresZero extra (usa seu PG existente)Plano gratuito limitado, pago acimaCusto de infra própria
Latência p95 (10k vetores)5-15ms com índice IVFFlat10-30ms (rede inclusa)3-10ms local
OperacionalUma extensão, sem serviço novoSaaS gerenciadoContainer para manter
Filtros híbridos (metadata + vetor)SQL nativo, joins normaisFiltros limitados por namespaceFiltros por payload
Escala acima de 1M vetoresPrecisa tuning de índice HNSWEscala automáticaSharding manual

Se você já usa PostgreSQL (e se usa Supabase como backend para Next.js, pgvector já vem habilitado), não há razão para adicionar um serviço externo com menos de 500k vetores. Acima de 1M vetores com queries abaixo de 10ms, considere Qdrant ou Pinecone.

Este post usa pgvector porque a maioria das aplicações Next.js já tem PostgreSQL.

Configurando pgvector no PostgreSQL

Habilite a extensão e crie a tabela de documentos com coluna vetorial:

SQL
-- Ativa pgvector. No Supabase, já vem disponível.
-- Em PostgreSQL local, instale: apt install postgresql-16-pgvector
CREATE EXTENSION IF NOT EXISTS vector;
 
CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  -- 1536 dimensões: compatível com text-embedding-3-small da OpenAI
  embedding vector(1536),
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
-- IVFFlat é mais rápido para criar que HNSW, suficiente até ~100k vetores.
-- lists = sqrt(número de linhas). Para 10k docs, ~100 lists.
CREATE INDEX ON documents
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

Se a base crescer acima de 100k documentos, troque IVFFlat por HNSW: melhor recall sem necessidade de reindexar periodicamente, ao custo de mais memória durante a construção do índice.

Gerando embeddings com a API da OpenAI

Instale as dependências:

Bash
npm install openai pg pgvector

Crie um módulo dedicado para gerar embeddings. Separar essa responsabilidade facilita trocar de provider depois (Cohere, Voyage AI, modelo local):

TypeScript
// src/lib/embeddings.ts
import OpenAI from "openai";
 
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});
 
// text-embedding-3-small custa $0.02/1M tokens, 5x mais barato que ada-002
// com qualidade comparável para busca semântica em português
const EMBEDDING_MODEL = "text-embedding-3-small";
 
export async function generateEmbedding(text: string): Promise<number[]> {
  // Limpa whitespace excessivo para não desperdiçar tokens
  const sanitized = text.replace(/\s+/g, " ").trim();
 
  const response = await openai.embeddings.create({
    model: EMBEDDING_MODEL,
    input: sanitized,
  });
 
  return response.data[0].embedding;
}
 
export async function generateEmbeddings(
  texts: string[]
): Promise<number[][]> {
  // A API aceita batch de até 2048 inputs por request.
  // Enviar em batch reduz latência vs. chamadas individuais.
  const response = await openai.embeddings.create({
    model: EMBEDDING_MODEL,
    input: texts.map((t) => t.replace(/\s+/g, " ").trim()),
  });
 
  return response.data.map((d) => d.embedding);
}

Indexando documentos via Route Handler

O Route Handler abaixo recebe conteúdo, gera o embedding e persiste no PostgreSQL. Em produção, esse endpoint deve ter autenticação via middleware para evitar que qualquer request indexe conteúdo arbitrário.

TypeScript
// src/app/api/documents/index/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Pool } from "pg";
import pgvector from "pgvector/pg";
import { generateEmbedding } from "@/lib/embeddings";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
 
// Registra o tipo vector uma vez no pool
pool.on("connect", async (client) => {
  await pgvector.registerType(client);
});
 
export async function POST(request: NextRequest) {
  const { title, content, metadata } = await request.json();
 
  if (!title || !content) {
    return NextResponse.json(
      { error: "title e content são obrigatórios" },
      { status: 400 }
    );
  }
 
  // Concatena título + conteúdo para o embedding capturar contexto completo.
  // Título sozinho perde nuance; conteúdo sozinho perde o resumo semântico.
  const textForEmbedding = `${title}\n\n${content}`;
  const embedding = await generateEmbedding(textForEmbedding);
 
  const client = await pool.connect();
  try {
    const result = await client.query(
      `INSERT INTO documents (title, content, embedding, metadata)
       VALUES ($1, $2, $3, $4)
       RETURNING id, title, created_at`,
      [title, content, pgvector.toSql(embedding), metadata ?? {}]
    );
 
    return NextResponse.json(result.rows[0], { status: 201 });
  } finally {
    client.release();
  }
}

Buscando por similaridade semântica

A query usa o operador <=> do pgvector para distância cosseno. Quanto menor o valor, mais similar:

TypeScript
// src/app/api/search/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Pool } from "pg";
import pgvector from "pgvector/pg";
import { generateEmbedding } from "@/lib/embeddings";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
 
pool.on("connect", async (client) => {
  await pgvector.registerType(client);
});
 
interface SearchResult {
  id: number;
  title: string;
  content: string;
  similarity: number;
}
 
export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get("q");
  const limit = parseInt(request.nextUrl.searchParams.get("limit") ?? "5", 10);
 
  if (!query) {
    return NextResponse.json(
      { error: "Parâmetro q é obrigatório" },
      { status: 400 }
    );
  }
 
  const queryEmbedding = await generateEmbedding(query);
 
  const client = await pool.connect();
  try {
    // 1 - distância cosseno = similaridade (0 a 1, onde 1 é idêntico)
    // Filtra resultados com similaridade mínima para evitar lixo
    const { rows } = await client.query<SearchResult>(
      `SELECT
         id,
         title,
         LEFT(content, 300) AS content,
         1 - (embedding <=> $1) AS similarity
       FROM documents
       WHERE 1 - (embedding <=> $1) > 0.3
       ORDER BY embedding <=> $1
       LIMIT $2`,
      [pgvector.toSql(queryEmbedding), limit]
    );
 
    return NextResponse.json({ results: rows, query });
  } finally {
    client.release();
  }
}

O threshold de 0.3 é um ponto de partida razoável para text-embedding-3-small em português. Textos completamente não relacionados ficam entre 0.1 e 0.25. Ajuste com base nos seus dados: se a busca retorna resultados irrelevantes, suba para 0.4. Se perde resultados válidos, desça para 0.25.

Componente de busca no cliente

Para a interface, um componente client que chama o endpoint com debounce:

TSX
// src/components/semantic-search.tsx
"use client";
 
import { useState, useCallback, useRef } from "react";
 
interface SearchResult {
  id: number;
  title: string;
  content: string;
  similarity: number;
}
 
export function SemanticSearch() {
  const [results, setResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);
  const [query, setQuery] = useState("");
  const debounceRef = useRef<NodeJS.Timeout | null>(null);
 
  const search = useCallback(async (searchQuery: string) => {
    if (searchQuery.length < 3) {
      setResults([]);
      return;
    }
 
    setLoading(true);
    try {
      const response = await fetch(
        `/api/search?q=${encodeURIComponent(searchQuery)}&limit=5`
      );
      const data = await response.json();
      setResults(data.results ?? []);
    } finally {
      setLoading(false);
    }
  }, []);
 
  const handleChange = (value: string) => {
    setQuery(value);
    // 400ms de debounce: cada keystroke gera um embedding ($0.02/1M tokens),
    // mas a latência da API (~200ms) torna debounce menor que 300ms inútil
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => search(value), 400);
  };
 
  return (
    <div>
      <input
        type="search"
        value={query}
        onChange={(e) => handleChange(e.target.value)}
        placeholder="Buscar por significado..."
        aria-label="Campo de busca semântica"
      />
      {loading && <p>Buscando...</p>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>
            <strong>{result.title}</strong>
            <span>
              {(result.similarity * 100).toFixed(1)}% de relevância
            </span>
            <p>{result.content}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

Esse componente é um Client Component porque depende de estado e eventos do navegador. A busca em si roda no servidor via Route Handler.

O que NÃO fazer

Anti-pattern 1: gerar embedding no cliente

TypeScript
// ERRADO: expõe a API key da OpenAI no bundle do navegador
"use client";
 
import OpenAI from "openai";
 
const openai = new OpenAI({
  apiKey: "sk-proj-...", // Vai parar no bundle. Qualquer um extrai.
});
 
async function searchOnClient(query: string) {
  const embedding = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: query,
  });
  // ...
}

A API key fica exposta no JavaScript do cliente. Qualquer pessoa inspeciona o bundle e usa sua chave. Gere embeddings no servidor, sempre.

Anti-pattern 2: armazenar embeddings como JSON em vez de tipo vector

SQL
-- ERRADO: sem índice vetorial, busca vira sequential scan
CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT,
  embedding JSONB  -- Funciona para guardar, mas não para buscar
);
 
-- Essa query precisa deserializar JSON, converter para array,
-- calcular distância manualmente. Em 10k docs, leva segundos.
SQL
-- CORRETO: tipo vector nativo com índice especializado
CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT,
  embedding vector(1536)
);
 
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
 
-- Busca por similaridade usa o índice. Em 10k docs, < 15ms.

A diferença é ordens de magnitude. JSONB não suporta operadores de distância vetorial e força scan completo da tabela.

Anti-pattern 3: não chunkar documentos longos

O modelo text-embedding-3-small aceita até 8191 tokens. Documentos longos (artigos de 3000+ palavras) perdem nuance quando comprimidos em um único vetor: o embedding vira uma média diluída de todos os tópicos.

TypeScript
// ERRADO: artigo inteiro vira um vetor genérico
const embedding = await generateEmbedding(artigoCompleto); // 5000 palavras
 
// CORRETO: divide em chunks com overlap para preservar contexto
function chunkText(text: string, chunkSize = 500, overlap = 50): string[] {
  const words = text.split(/\s+/);
  const chunks: string[] = [];
 
  for (let i = 0; i < words.length; i += chunkSize - overlap) {
    chunks.push(words.slice(i, i + chunkSize).join(" "));
  }
 
  return chunks;
}

Chunks de 300-500 palavras com overlap de 50 palavras funcionam para a maioria dos conteúdos textuais. O overlap evita que uma frase relevante fique cortada na fronteira entre dois chunks.

Otimizações para produção

Três pontos que separam um protótipo de algo que aguenta tráfego real:

Cache de embeddings de query: se muitos usuários buscam termos parecidos ("como fazer deploy", "deploy next.js"), o embedding da query é quase idêntico. Um cache em Redis com TTL de 1 hora evita chamadas repetidas à API da OpenAI. Se você já usa BullMQ com Redis, o Redis já está disponível.

Indexação assíncrona: não gere embeddings no request de criação do documento. Coloque numa fila e processe em background. Se a API da OpenAI estiver lenta ou fora do ar, o documento é salvo normalmente e o embedding é gerado quando a fila processar. Esse padrão de processamento assíncrono é o mesmo para qualquer operação que depende de serviço externo.

Reindexação ao trocar modelo: se você migrar de text-embedding-ada-002 (1536 dims) para text-embedding-3-large (3072 dims), todos os vetores existentes são incompatíveis. Planeje uma migration sem downtime: adicione coluna nova, popule em background, troque a query, remova a coluna antiga.

Quando busca semântica não é a resposta certa

Busca semântica resolve "o usuário não sabe o termo exato". Mas se o domínio tem vocabulário controlado (códigos de produto, nomes de cidades, SKUs), busca textual com tsvector do PostgreSQL é mais rápida, mais barata e mais previsível. Use embeddings quando o vocabulário é aberto e a intenção importa mais que a palavra exata.

Para aplicações que combinam ambos (filtro por categoria + busca por significado dentro da categoria), a query híbrida funciona: filtre por metadata com WHERE comum e ordene por distância vetorial. O pgvector lida com isso nativamente porque é SQL.

FAQ

Quanto custa gerar embeddings com a OpenAI?

O modelo text-embedding-3-small custa $0.02 por milhão de tokens. Um artigo de 1000 palavras tem aproximadamente 1300 tokens. Indexar 10.000 artigos custa cerca de $0.26. Cada busca do usuário gera um embedding da query (tipicamente 5-20 tokens), então o custo por busca é negligível.

Posso usar embeddings locais em vez da OpenAI?

Sim. Modelos como all-MiniLM-L6-v2 do Sentence Transformers rodam localmente com 384 dimensões. A qualidade em português é inferior ao text-embedding-3-small, mas o custo é zero e não há dependência de API externa. Para português especificamente, o modelo multilingual-e5-large oferece bom equilíbrio entre qualidade e tamanho.

pgvector aguenta quantos vetores?

Com índice IVFFlat, até 100k vetores com latência abaixo de 20ms em hardware modesto. Com HNSW, até 1M vetores com recall acima de 95%. Acima de 5M vetores em uma única tabela, considere particionamento ou um banco vetorial dedicado como Qdrant.

Como atualizar o embedding quando o conteúdo muda?

Regenere o embedding e faça UPDATE na coluna. Se a atualização é frequente, use o padrão de fila: dispare um job assíncrono que regenera o embedding e atualiza o registro. Não regenere no request de edição para não bloquear o usuário.

Busca semântica funciona bem em português?

O text-embedding-3-small da OpenAI foi treinado com dados multilíngues e funciona com qualidade comparável em português e inglês. Modelos open-source variam: verifique benchmarks específicos para português (MTEB Leaderboard filtrando por "por") antes de escolher.

A posição que defendo

Busca semântica com pgvector é a escolha mais pragmática para aplicações Next.js que já usam PostgreSQL e têm menos de 500k documentos. Você não precisa de Pinecone, não precisa de um serviço vetorial separado, não precisa de infraestrutura nova. Uma extensão, uma coluna, um índice. A complexidade real está em chunkar bem o conteúdo e calibrar o threshold de similaridade, não na infraestrutura. Se você já tem o banco, tem a busca: o resto é calibração.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Guias de integração relacionados

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.