P PasteCode
Playbook

Prompt-para-PR: Adicionar Pesquisa de Texto Completo no PostgreSQL

POP para adicionar pesquisa de texto completo nativa do PostgreSQL com tsvector, índice GIN, ts_rank e uma API de busca Next.js — sem necessidade de serviço de terceiros.

CursorClaude CodeCodexWindsurf Next.jsPostgreSQLTypeScript
.md .json Dificuldade: Difícil Atualizado 8 de jun. de 2026

A pesquisa de texto completo embutida do PostgreSQL atende à maioria das necessidades de busca de produtos sem Elasticsearch ou Algolia. Este guia adiciona uma coluna tsvector, um índice GIN e uma consulta de busca ranqueada por trás de uma rota de API Next.js.

1. Requisito

Adicionar pesquisa de texto completo em uma tabela posts (colunas: title, body, tags). A busca deve retornar resultados ranqueados por relevância com destaque de trechos. A implementação deve usar uma coluna tsvector gerada (não computada no momento da consulta) para que o índice GIN seja utilizado.

2. Primeiro Prompt

First Prompt
Add native PostgreSQL full-text search to the posts table in this project.
Database: PostgreSQL. ORM/query builder: [Drizzle / postgres.js — use whichever
is already in src/db/].
Step 1 — migration:
Create a migration file (or Drizzle schema change) that:
a. Adds a generated column:
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B') ||
setweight(to_tsvector('english', coalesce(tags, '')), 'C')
) STORED;
b. Creates a GIN index on search_vector:
CREATE INDEX posts_search_idx ON posts USING gin(search_vector);
Step 2 — query function:
Create `src/lib/queries/search.ts` exporting `searchPosts(q: string, limit = 10)`.
The query must:
- Convert the user query to a tsquery: `websearch_to_tsquery('english', $1)`.
- Filter: `search_vector @@ query`.
- Rank: `ts_rank(search_vector, query) DESC`.
- Return: id, title, ts_headline('english', body, query,
'MaxWords=30, MinWords=15, ShortWord=3, HighlightAll=false') AS snippet.
- Use parameterized query (no string interpolation of the user input).
Step 3 — API route:
Create `src/app/api/search/route.ts` (GET). Read `q` from URL search params.
Return 400 if q is empty or shorter than 2 chars. Return JSON array of results.
Add cache-control: public, max-age=60.
Step 4 — do not touch any UI components.

3. Alterações de Arquivos Esperadas

src/db/migrations/<timestamp>_add_search_vector.sql (new — or Drizzle migration)
src/db/schema.ts (search_vector column if Drizzle)
src/lib/queries/search.ts (new — searchPosts function)
src/app/api/search/route.ts (new — GET endpoint)

4. Lista de Verificação

  • GENERATED ALWAYS AS ... STORED — a coluna é armazenada (não virtual), para que o índice GIN possa ser usado. Confirme que a palavra-chave STORED está presente.
  • websearch_to_tsquery é usado (não to_tsquery) — lida com consultas de múltiplas palavras e frases a partir de entrada não confiável sem erros de sintaxe.
  • A consulta fornecida pelo usuário é passada como um parâmetro SQL, nunca interpolada.
  • ts_rank ordena os resultados — não por ordem alfabética ou de inserção.
  • ts_headline tem comprimento limitado (MaxWords=30) para evitar retornar trechos enormes.
  • O nome do índice GIN é explícito — mais fácil de remover/recriar se necessário.
  • A rota da API valida o comprimento mínimo da consulta (2 caracteres) para evitar consultas tsquery com varredura completa da tabela como 'a':*.
  • Cache-Control: public, max-age=60 é definido na resposta — resultados de busca podem ser armazenados em cache por curta duração.

5. Comandos de Teste

Terminal window
# Run the migration
npx drizzle-kit migrate
# or: psql $DATABASE_URL < migration.sql
# Verify the generated column and index exist
psql $DATABASE_URL -c "\d posts" | grep search_vector
psql $DATABASE_URL -c "\di posts_search_idx"
# Query performance — confirm index scan, not seq scan
psql $DATABASE_URL -c "
EXPLAIN ANALYZE
SELECT id FROM posts
WHERE search_vector @@ websearch_to_tsquery('english', 'your test query')
LIMIT 10;
" | grep "Index Scan"
# Test the API endpoint
bun dev &
curl "http://localhost:3000/api/search?q=your+test+query" | jq .
# Confirm empty query returns 400
curl -o /dev/null -w "%{http_code}" "http://localhost:3000/api/search?q="
# Expect: 400

6. Falhas Comuns

  • Varredura sequencial em vez de varredura por índice — coluna VIRTUAL (não STORED). Apenas colunas geradas STORED podem ser indexadas no PostgreSQL. Confirme que a migração usa STORED.
  • to_tsquery lança erro para entrada com múltiplas palavrasto_tsquery('english', 'quick brown') é um erro de sintaxe. Sempre use websearch_to_tsquery para strings fornecidas pelo usuário.
  • ts_headline retorna o corpo inteiroHighlightAll=true acidentalmente definido, ou MaxWords não definido. Verifique a string de opções.
  • Índice GIN não usado para consultas LIKE — garanta que a cláusula WHERE use o operador @@, não LIKE ou ILIKE. Pesquisa de texto completo e LIKE são separados.
  • Migração falha em dados existentes — a coluna GENERATED é populada na criação; em tabelas grandes isso pode ser lento. Execute em uma transação com verificação de progresso.

7. Prompt de Correção

Fix Prompt
EXPLAIN ANALYZE shows a sequential scan instead of an index scan on the
posts table. The search_vector column was added as VIRTUAL (or without
the STORED keyword) so PostgreSQL cannot create a GIN index on it.
Fix the migration:
ALTER TABLE posts DROP COLUMN search_vector;
ALTER TABLE posts ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX posts_search_idx ON posts USING gin(search_vector);
Confirm the word STORED appears in the column definition.

8. Descrição do PR

PR description
## Feature: PostgreSQL native full-text search on posts
- Generated `tsvector` column (`STORED`) with weighted fields:
title (A), body (B), tags (C)
- GIN index `posts_search_idx` — index scans confirmed via `EXPLAIN ANALYZE`
- `searchPosts(q, limit)` uses `websearch_to_tsquery` (safe for user input)
and `ts_rank` for relevance ordering
- `ts_headline` snippets (max 30 words) with matched terms highlighted
- GET `/api/search?q=` — validates query length, returns ranked JSON,
`Cache-Control: public, max-age=60`
No third-party search dependency added.