SQLite FTS5 e Cloudflare D1

Padrões de busca na edge com D1

Autocomplete com prefixo, paginação, limites de D1 e quando externalizar para Vectorize/OpenSearch.

Intermediário 45 min 33 pontos Leitura 0%

Nesta aula você vai

  • Implementar autocomplete com prefixos FTS5 na edge
  • Paginar resultados de forma estável dentro dos limites do D1
  • Decidir quando manter FTS no D1 ou externalizar para Vectorize/OpenSearch

Padrões de busca na edge com D1

Objetivos

Nesta aula você vai:

  • Implementar autocomplete com prefixos FTS5 na edge
  • Paginar resultados de forma estável dentro dos limites do D1
  • Decidir quando manter FTS no D1 ou externalizar para Vectorize/OpenSearch

Introdução

FTS5 no D1 cobre bem busca lexical em conjuntos de dados moderados. Na edge, o desafio é UX responsiva (autocomplete), paginação previsível e saber a hora de sair do SQLite embutido para um motor dedicado ou busca vetorial.

Conteúdo

Autocomplete com prefixo

FTS5 aceita token*. Para autocomplete preditivo:

SELECT a.id, a.title
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH 'titul*'  -- ex.: usuário digitou "titul"
  AND a.status = 'published'
ORDER BY bm25(articles_fts)
LIMIT 8;

No Worker, converta a digitação em um único prefixo seguro:

function prefixQuery(raw) {
  const t = String(raw || '')
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[^\p{L}\p{N}]/gu, '')
    .slice(0, 32);
  if (t.length < 2) return null;
  return `"${t}"*`; // prefixo FTS5
}

Dicas:

  • Debounce no cliente (150–300 ms).
  • Cache curto em Cache API / KV para os prefixos mais quentes.
  • Prefira buscar só no título para autocomplete; corpo gera ruído.

Paginação: OFFSET vs keyset

LIMIT/OFFSET é simples e degrada com OFFSET alto:

-- frágil em páginas profundas
ORDER BY bm25(articles_fts) LIMIT 20 OFFSET 200;

Para listagens estáveis, combine score com id e use keyset quando possível. Com BM25 puro o keyset é difícil (score flutuante por query). Padrões pragmáticos na edge:

  1. Só primeiras N páginas com OFFSET pequeno (ex.: máx. página 5).
  2. Cursor opaco = (score, id) da última linha, reexecutando a mesma query FTS e filtrando no app — ou ordenar por id após um pré-filtro FTS em subquery limitada.
  3. Materializar top-K em cache para queries populares.
SELECT a.id, a.title, bm25(articles_fts) AS score
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH ?1
  AND a.status = 'published'
ORDER BY score, a.id
LIMIT 21;  -- 20 + peek

Se vierem 21 linhas, há próxima página; descarte a última no response.

Limites práticos do D1

Considere externalizar quando:

Sinal Interpretação
DB grande / crescimento rápido Limites de tamanho e backup do D1
QPS de busca alto e variável Contenção e CPU de Worker
Relevância multilíngue/sinônimos FTS5 lexical insuficiente
Facetas, fuzzy agressivo, analytics de query OpenSearch/Meilisearch/Typesense
“busca por significado” Vectorize + embeddings

Quando usar Vectorize ou OpenSearch

  • D1 FTS5: docs internos, blogs, catálogos pequenos/médios, busca “contém palavras”, custo baixo, mesma stack Workers.
  • Vectorize (ou similar): recomendação semântica, FAQ por intenção, multilingual paraphrase.
  • OpenSearch / Elasticsearch: facetas ricas, pipelines de análise, alto volume, equipe já operando o stack.
  • Híbrido: FTS lexical no D1 (ou OpenSearch) + re-rank semântico com embeddings nos top-K.
[Cliente] → Worker
              ├─ MATCH FTS5 (top 50)
              └─ (opcional) re-rank Vectorize nos IDs
                    → resposta

Exemplos práticos

export async function suggest(env, raw) {
  const t = String(raw || '')
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[^\p{L}\p{N}]/gu, '')
    .slice(0, 32);
  if (t.length < 2) return [];

  const match = `title: "${t}"*`;
  const cacheKey = new Request('https://suggest.local/' + match);
  const hit = await caches.default.match(cacheKey);
  if (hit) return hit.json();

  const { results } = await env.DB.prepare(
    `SELECT a.id, a.title
     FROM articles_fts
     JOIN articles a ON a.id = articles_fts.rowid
     WHERE articles_fts MATCH ?1 AND a.status = 'published'
     ORDER BY bm25(articles_fts, 10.0, 1.0)
     LIMIT 8`
  )
    .bind(match)
    .all();

  const res = Response.json(results, {
    headers: { 'cache-control': 'public, max-age=30' },
  });
  await caches.default.put(cacheKey, res.clone());
  return results;
}

Problemas e como resolver

Problema Causa Mitigação
Autocomplete lento Debounce ausente + OFFSET Debounce, LIMIT 8, cache
Página 50 demora OFFSET profundo Cap de páginas; keyset/top-K
Resultados “sem sentido” Só lexical Sinônimos no app ou Vectorize
Índice diverge Escrita bypass triggers Toda escrita pela tabela + triggers

Resumo

Na edge, use prefixos FTS5 para autocomplete, paginação rasa com LIMIT e leitura extra de uma linha (peek), e cache para consultas frequentes. D1 FTS5 escala até um ponto; além disso, combine ou migre para Vectorize (semântica) e/ou OpenSearch (relevância e facetas industriais).