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.
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:
| entrada | mensagem |
|---|---|
() | 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 REPLACEnos trechos. Ele troca o rowid, o índice continua apontando pro antigo e o texto novo nunca é indexado. UseON 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 mesmobatch(). - A transação é o
batch()do cliente. Num pipeline HTTP comBEGINeCOMMITna mão, a instrução que falha não para as seguintes, oCOMMITroda e fica meio documento gravado. Obatch()é atômico.
Testei tudo isso no banco real. Nenhuma dessas falhas dá erro: a busca só vai piorando um pouco a cada ingestão.