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.
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:
- Só primeiras N páginas com OFFSET pequeno (ex.: máx. página 5).
- Cursor opaco =
(score, id)da última linha, reexecutando a mesma query FTS e filtrando no app — ou ordenar poridapós um pré-filtro FTS em subquery limitada. - 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).