Pular para o conteúdo
Rogerio Lima

· IA, Backend · 3 min de leitura

Turso FTS5 no RAG sem erro 500

Se você joga a pergunta do usuário direto no MATCH do FTS5, uma hora vem um erro 500. Mostro como eu monto a consulta e o que estraga o índice na hora de gravar.

person holding magnifying glass
Foto: Mick Haupt · Unsplash

No post sobre busca híbrida eu mostrei que, num benchmark parecido com documentação técnica, a busca por palavra não aumentou o recall. Mesmo assim o FTS5 continua no meu RAG, por causa do tipo de pergunta que um portal de API recebe: nome de endpoint, código de erro, o título exato de uma página. Vetor é bom com sentido, e pra palavra exata eu uso o FTS5.

O cuidado é pra ele não virar fonte de erro 500, de resultado vazio ou de vazamento.

Tokenizer: com ou sem acento, mesmo resultado

CREATE VIRTUAL TABLE chunks_fts USING fts5(
  chunk_id UNINDEXED,
  text,
  tokenize = "unicode61 remove_diacritics 2"
);

Com remove_diacritics 2, "orientações" e "orientacoes" viram o mesmo termo no índice. Em português isso faz diferença, porque muita gente digita sem acento. O chunk_id fica UNINDEXED porque só serve pro JOIN.

A pergunta do usuário nunca vai direto pro MATCH

O MATCH tem sintaxe própria, e uma pergunta comum quebra o parser: um ? no fim, umas aspas coladas de outro lugar, C++, um "a AND" perdido no meio. Aí vem fts5: syntax error, e as duas saídas óbvias são ruins: devolver 500 pra uma pergunta mal digitada, ou tentar de novo sem o filtro que falhou.

Por isso eu monto a expressão em vez de repassar o texto:

const TERM = /[\p{L}\p{N}]+/gu;

export function toMatchExpression(query: string): string {
  const terms = [...new Set([...query.matchAll(TERM)].map((m) => m[0].toLowerCase()))];
  if (terms.length === 0) throw new InvalidQueryError("a pergunta não tem termo pesquisável");
  return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(" OR ");
}

Cada sequência de letras e números vira uma string entre aspas. Dentro das aspas, or é só a palavra "or", então nenhuma pergunta consegue mudar o formato da consulta. E o \p{L} faz diferença: com um \w sem Unicode, "orientações" virava "orienta" e "es".

O usuário perde a sintaxe de busca, então ninguém consegue escrever titulo:pix ou pix* por esse caminho. Pra um chatbot, tudo bem.

Por que OR e não AND

Entre termos soltos, o FTS5 usa AND. Numa pergunta em linguagem natural, isso faz a busca por texto voltar vazia quase sempre, e a busca híbrida não percebe: a vetorial continua respondendo, a fusão continua devolvendo 20 trechos, e metade do sistema está morta sem ninguém saber. Com OR, ela continua trazendo candidatos e o bm25() cuida da ordenação.

A frase exata, com peso

O OR acha os termos espalhados. Quando a pergunta é uma frase de verdade, uma segunda consulta procura a frase na ordem:

export function phraseExpression(query: string): string | null {
  const tokens = (query.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).slice(0, 24);
  return tokens.length < 2 ? null : `"${tokens.join(" ")}"`;
}

Essa busca entra na fusão com peso 2, e o motivo está no post sobre RRF com peso.

Quando o FTS5 recusa mesmo assim

Se um dia a expressão montada for recusada, a resposta tem que ser "pergunta inválida" (4xx), nunca 500. O driver não devolve um código de erro, só a mensagem, e nem toda mensagem do FTS5 começa com fts5:. Testei no SQLite 3.45.1:

entradamensagem
()fts5: syntax error near ")"
" AND *unterminated string
*unknown special query:
const FTS5_REFUSAL = /fts5:|unterminated string|unknown special query/i;

Se eu olhasse só o prefixo, duas das três viravam erro do servidor.

Gravando no índice sem deixar lixo

A tabela FTS é independente (não usa content=), e isso muda como você grava:

  • Não use INSERT OR REPLACE nos trechos. Ele troca o rowid, o índice continua apontando pro antigo e o texto novo nunca é indexado. Use ON CONFLICT DO UPDATE.
  • A cascata não chega no FTS. Apagar o documento apaga os trechos pela foreign key, mas as entradas do índice ficam lá. Apague nas duas tabelas, pelo chunk_id, no mesmo batch().
  • A transação é o batch() do cliente. Num pipeline HTTP com BEGIN e COMMIT na mão, a instrução que falha não para as seguintes, o COMMIT roda e fica meio documento gravado. O batch() é atômico.

Testei tudo isso no banco real. Nenhuma dessas falhas dá erro: a busca só vai piorando um pouco a cada ingestão.

Travou em alguma etapa? Chamar no WhatsApp

← Todos os posts