SQLite FTS5 e Cloudflare D1

Full-Text Search no Cloudflare D1

Como usar FTS5 no D1 (extensão suportada), content tables, triggers de sync e cuidados em Workers.

Intermediário 48 min 34 pontos Leitura 0%

Nesta aula você vai

  • Criar índices FTS5 no D1 com content table e content_rowid
  • Manter o índice sincronizado com triggers AFTER INSERT/UPDATE/DELETE
  • Consultar via Workers respeitando limites e boas práticas do D1

Full-Text Search no Cloudflare D1

Objetivos

Nesta aula você vai:

  • Criar índices FTS5 no D1 com content table e content_rowid
  • Manter o índice sincronizado com triggers AFTER INSERT/UPDATE/DELETE
  • Consultar via Workers respeitando limites e boas práticas do D1

Introdução

Cloudflare D1 é SQLite gerenciado na edge. A extensão fts5 (sempre em minúsculas na declaração) está disponível, o que permite full-text search sem um serviço externo — ideal para catálogos, docs e busca de conteúdo em apps Workers.

O padrão recomendado em produção é: tabela relacional canônica + virtual table FTS com content='...' + triggers de sincronização.

Conteúdo

Declaração no D1

Em migrações D1 (wrangler d1 migrations):

-- migrations/0001_fts.sql
CREATE TABLE IF NOT EXISTS articles (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'draft',
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
  title,
  body,
  content='articles',
  content_rowid='id',
  tokenize='unicode61 remove_diacritics 2'
);

Atenção: use fts5 em minúsculo. content aponta para a tabela real; content_rowid deve ser a PK inteira usada como rowid do índice.

Triggers de sincronização

Com external content, inserts na tabela articles não atualizam o índice sozinhos. O padrão FTS5:

CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
  INSERT INTO articles_fts(rowid, title, body)
  VALUES (new.id, new.title, new.body);
END;

CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
  INSERT INTO articles_fts(articles_fts, rowid, title, body)
  VALUES ('delete', old.id, old.title, old.body);
END;

CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
  INSERT INTO articles_fts(articles_fts, rowid, title, body)
  VALUES ('delete', old.id, old.title, old.body);
  INSERT INTO articles_fts(rowid, title, body)
  VALUES (new.id, new.title, new.body);
END;

A forma 'delete' no primeiro argumento é o comando especial do FTS5 para remover do índice com external content.

Backfill

Para dados já existentes:

INSERT INTO articles_fts(rowid, title, body)
SELECT id, title, body FROM articles;

Rode uma vez após criar a virtual table (e antes ou depois dos triggers, conforme o estado do índice — evite duplicar se triggers já popularem novos inserts).

Consulta no Worker

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const q = buildFtsQuery(url.searchParams.get('q'));
    if (!q) {
      return Response.json({ results: [] });
    }

    const { results } = await env.DB.prepare(
      `SELECT a.id, a.title,
              snippet(articles_fts, 1, '<mark>', '</mark>', '…', 32) AS snippet,
              bm25(articles_fts, 10.0, 1.0) AS score
       FROM articles a
       JOIN articles_fts ON articles_fts.rowid = a.id
       WHERE articles_fts MATCH ?1
         AND a.status = 'published'
       ORDER BY score
       LIMIT 20`
    )
      .bind(q)
      .all();

    return Response.json({ results });
  },
};

Use prepare + bind — não interpolar a query FTS com concatenação insegura. Ainda assim, valide/sanitize tokens porque a sintaxe FTS é uma linguagem própria.

Cuidados específicos do D1 / Workers

  1. Tamanho e limites: D1 tem limites de tamanho de DB, linhas retornadas e tempo de CPU do Worker. Paginação curta (LIMIT/OFFSET ou keyset).
  2. Latência de escrita: triggers adicionam custo a cada INSERT/UPDATE/DELETE — aceitável para CMS; pesado em ingestão massiva (prefira batch + rebuild controlado).
  3. Rebuild: INSERT INTO articles_fts(articles_fts) VALUES('rebuild'); quando o índice corromper ou após mudança de tokenizer (planeje downtime/janela).
  4. Transações: mantenha escrita na tabela + consistência dos triggers na mesma transação quando usar batch API.
  5. Não confunda com Vectorize: FTS5 é lexical; embeddings vão para Vectorize ou similar.

Exemplos práticos

# wrangler.toml (trecho)
[[d1_databases]]
binding = "DB"
database_name = "app-db"
database_id = "<id>"
-- Busca com filtro relacional
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 'd1 AND fts5'
  AND a.status = 'published'
ORDER BY score
LIMIT 10;
function buildFtsQuery(raw) {
  if (!raw) return null;
  const parts = raw
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
    .trim()
    .split(/\s+/)
    .filter((t) => t.length >= 2)
    .slice(0, 10);
  if (!parts.length) return null;
  return parts.map((t) => `"${t}"`).join(' AND ');
}

Problemas e como resolver

Problema Causa Mitigação
Índice desatualizado Triggers ausentes ou falhos Recriar triggers; backfill; rebuild
Erro ao criar FTS Nome FTS5 / sintaxe Usar USING fts5(...)
Match sem linhas publicadas Só filtro FTS Join + status
Timeout no Worker Query ampla / OFFSET grande Query restrita, LIMIT baixo, keyset

Resumo

No D1, modele content + content_rowid, sincronize com triggers INSERT/UPDATE/DELETE, faça backfill e consulte com MATCH + bm25/snippet a partir do Worker. Trate a string FTS como linguagem a sanitizar e respeite limites da edge. Próxima aula: padrões de autocomplete, paginação e quando sair do D1.