Implementar busca com Claude Code: Postgres, Meilisearch e Algolia
Guia prático de busca com Claude Code: requisitos, índice, sincronização, filtros, debounce UI, testes e rollout.
Busca é uma experiência de descoberta
Uma funcionalidade de busca recebe a consulta do usuário, encontra candidatos, aplica filtros, ordena e destaca os trechos relevantes. UmLIKE '%term%' pode resolver uma tabela interna, mas não sustenta um blog monetizado, uma base de conhecimento ou um catálogo de cursos. Boa busca aumenta PV, recupera conteúdos antigos e transforma consultas sem resultado em pauta editorial.
A experiência prática de Masa é que começar pela UI costuma gerar retrabalho. Antes do componente visual, defina campos indexados, dados públicos e privados, locale, ranking, sincronização e logs. Claude Code implementa rápido, mas precisa de requisitos claros.
Leituras relacionadas: busca Algolia com Claude Code, desenvolvimento de API com Claude Code e otimização de performance com Claude Code.
Separe os casos de uso
| Caso de uso | Exemplos | O que importa | Melhor opção |
|---|---|---|---|
| Busca de conteúdo | blog, FAQ, docs | peso do título, resumo, tags, idioma | Postgres full-text ou Meilisearch |
| Catálogo | produtos, cursos, templates | facetas, ordenação, sinônimos, analytics | Meilisearch ou Algolia |
| Admin | clientes, cobranças, logs | permissões, filtros exatos, auditoria | Postgres primeiro |
| Conteúdo multilíngue | páginas pt/en/ja | locale, keywords locais, URL correta | Meilisearch ou Algolia |
Se os dados já estão no Postgres e o volume é moderado, comece com full-text search. Quando precisar de tolerância a erros, facetas e relevância mais pronta, Meilisearch é uma evolução natural. Quando busca afeta receita, leads ou venda de cursos, Algolia tende a compensar.
Prompt de requisitos para Claude Code
Implemente busca de produção em uma aplicação Next.js existente.
Objetivo:
- Buscar artigos publicados e aumentar descoberta de conteúdo.
- Suportar query, locale, category e tags.
- Buscar em title, summary, tags e body, com maior peso em title.
- Retornar campos suficientes para cards de resultado e highlight.
Restrições:
- Não retornar drafts, registros privados, emails, notas internas ou conteúdo restrito.
- Não expor chaves admin ou write no navegador.
- Usar debounce de 300 ms e AbortController na UI.
- Registrar buscas sem resultado, buscas lentas e cliques.
Entregáveis:
- Nota comparando Postgres full-text, Meilisearch e Algolia.
- Schema de índice.
- Job de sincronização.
- Rota /api/search.
- UI React.
- Testes e checklist de rollout.
Peça para Claude Code ler schema, frontmatter MDX, regras de autenticação e estrutura de URL antes de editar. Em busca, vazamento de dados é mais grave do que relevância imperfeita.
Escolha do backend de busca
A documentação oficial de PostgreSQL sobre Full Text Search cobretsvector, tsquery e ranking. Meilisearch tem um quick start simples e documentação de filtering, sorting and faceting. Algolia oferece UI e analytics fortes com InstantSearch.js e React InstantSearch.
Schema Postgres
CREATE TABLE IF NOT EXISTS articles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
locale text NOT NULL,
status text NOT NULL CHECK (status IN ('draft', 'published', 'private')),
title text NOT NULL,
summary text NOT NULL,
body text NOT NULL,
category text NOT NULL,
tags text[] NOT NULL DEFAULT '{}',
popularity integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
search_vector tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(array_to_string(tags, ' '), '')), 'B') ||
setweight(to_tsvector('simple', coalesce(body, '')), 'C')
) STORED
);
CREATE INDEX IF NOT EXISTS articles_search_vector_idx
ON articles USING GIN (search_vector);
CREATE INDEX IF NOT EXISTS articles_locale_status_idx
ON articles (locale, status, updated_at DESC);
O ponto principal é peso. Um termo no título deve valer mais do que uma ocorrência perdida no corpo. Isso aproxima o ranking da intenção real do leitor.
Sincronização com Meilisearch
O banco ou CMS continua sendo a fonte de verdade. O índice recebe só campos públicos.
// scripts/sync-meilisearch.ts
import "dotenv/config";
import { MeiliSearch } from "meilisearch";
type ArticleRecord = {
id: string;
title: string;
summary: string;
body: string;
locale: string;
status: "published";
category: string;
tags: string[];
url: string;
popularity: number;
updatedAtTimestamp: number;
};
const client = new MeiliSearch({
host: process.env.MEILISEARCH_HOST ?? "http://127.0.0.1:7700",
apiKey: process.env.MEILISEARCH_ADMIN_KEY
});
const index = client.index<ArticleRecord>("articles");
await index.updateSettings({
searchableAttributes: ["title", "summary", "body", "tags"],
filterableAttributes: ["locale", "status", "category", "tags"],
sortableAttributes: ["updatedAtTimestamp", "popularity"],
displayedAttributes: ["id", "title", "summary", "locale", "category", "tags", "url"]
});
const task = await index.addDocuments(
[
{
id: "pt_claude-code-search-functionality",
title: "Implementar busca com Claude Code",
summary: "Guia prático sobre backend, índice, UI, testes e rollout.",
body: "Texto público extraído de MDX ou CMS.",
locale: "pt",
status: "published",
category: "use-cases",
tags: ["Claude Code", "busca", "full-text"],
url: "/pt/blog/claude-code-search-functionality",
popularity: 18,
updatedAtTimestamp: 1780272000
}
],
{ primaryKey: "id" }
);
console.log(`Queued Meilisearch task ${task.taskUid}`);
Não exagere nas facetas. Para conteúdo,category, tags elocale costumam bastar. Filtros de permissão devem ficar no servidor ou em chaves de busca restritas.
UI React com debounce
// components/ArticleSearchBox.tsx
"use client";
import { useEffect, useMemo, useState } from "react";
type SearchHit = {
id: string;
title: string;
summary: string;
url: string;
category: string;
};
function useDebounce<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
export function ArticleSearchBox({ locale = "pt" }: { locale?: string }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [hits, setHits] = useState<SearchHit[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const params = useMemo(() => {
const next = new URLSearchParams({ q: debouncedQuery, locale });
if (category) next.set("category", category);
return next;
}, [category, debouncedQuery, locale]);
useEffect(() => {
if (debouncedQuery.trim().length < 2) {
setHits([]);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?${params.toString()}`, { signal: controller.signal })
.then((response) => {
if (!response.ok) throw new Error("Search request failed");
return response.json();
})
.then((data: { hits: SearchHit[] }) => setHits(data.hits))
.catch((error) => {
if (error.name !== "AbortError") console.error(error);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [debouncedQuery, params]);
return (
<section aria-label="Article search">
<input
aria-label="Search keywords"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search Claude Code articles"
/>
<select aria-label="Category" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="">All</option>
<option value="use-cases">Use cases</option>
<option value="advanced">Advanced</option>
</select>
{loading && <p>Searching...</p>}
<ul>
{hits.map((hit) => (
<li key={hit.id}>
<a href={hit.url}>{hit.title}</a>
<p>{hit.summary}</p>
</li>
))}
</ul>
</section>
);
}
Testes, rollout e armadilhas
As armadilhas são específicas: indexar rascunhos, expor admin key, enviar campos privados para um provedor, adicionar sinônimos demais, criar facetas demais e nunca revisar buscas sem resultado. Teste queries curtas, filtropublished, filtro de categoria e layout mobile.
// tests/search-query.test.ts
import { describe, expect, it } from "vitest";
function shouldSearch(query: string) {
return query.trim().length >= 2 && query.length <= 80;
}
describe("search request rules", () => {
it("rejects empty and one-character queries", () => {
expect(shouldSearch("")).toBe(false);
expect(shouldSearch("a")).toBe(false);
expect(shouldSearch("api")).toBe(true);
});
});
Antes do lançamento, confirme índice public-only, estado de zero resultado, p95 de latência, limite para queries longas, logs sem dados pessoais e links internos naturais. Depois, revise semanalmente buscas sem resultado e baixo CTR para melhorar títulos, sinônimos, links internos e novos artigos.
ClaudeCodeLab apoia desenho de busca, treinamento em Claude Code e revisão de implementação. Para suporte estruturado, a página de treinamento e consultoria é um bom próximo passo.
Resumo
A ordem segura é: requisitos, escolha do motor, schema de índice, sync job, filtros e facetas, UI com debounce, testes e rollout. Na prática, restringir cedo os campos indexados e retornados reduz correções; revisar buscas sem resultado gera ideias diretas para crescimento de PV.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Workflow Obsidian para CLAUDE.md com Claude Code
Transforme notas de trabalho do Obsidian em notas operacionais CLAUDE.md para preservar contexto.
Claude Code Revenue CTA Routing: artigos para PDF, Gumroad e consultoria
Um fluxo com Claude Code para levar leitores ao PDF grátis, Gumroad ou consultoria conforme intenção.
Regras de handoff para equipes com Claude Code: evidências, permissões, rollback e receita
Formato prático para entregar trabalho do Claude Code com prova, permissões, rollback, PDF grátis, Gumroad e consultoria.