Full-text search with FTS5

SQLite · Advanced · 14 min

FTS5 gives apps proper search over notes, messages or documents with no extra service.

LIKE '%word%' scans every row and can't rank results. FTS5 is SQLite's full-text search extension (included in most builds, including Python's). It stores an inverted index from tokens to rows.

  • Create a virtual table: CREATE VIRTUAL TABLE doc_fts USING fts5(title, body). All columns are text and untyped.
  • Query with MATCH: WHERE doc_fts MATCH 'sqlite AND wal'. The query syntax supports implicit AND between terms, OR, NOT, "exact phrases", prefix search data*, column filters title:sqlite and NEAR(a b, 5).
  • Rank with the hidden rank column (ORDER BY rank), which defaults to bm25; bm25(doc_fts, 10.0, 1.0) weights the title higher.
  • highlight(doc_fts, 1, '[', ']') and snippet(...) produce marked-up excerpts.
  • Tokenizers: unicode61 (default, case-folding and diacritic removal), porter for English stemming (tokenize='porter unicode61'), and trigram for substring search.

To avoid storing text twice, use an external-content table (content='docs', content_rowid='id') that indexes rows of a normal table; then you keep the index in sync with triggers on that table.

User input can contain characters that are FTS5 syntax, so a raw search string may raise a syntax error; quote it as a phrase (double the " characters inside) when you don't want operators.

Example

import sqlite3

con = sqlite3.connect(":memory:")
con.execute("CREATE VIRTUAL TABLE note USING fts5(title, body, tokenize='porter unicode61')")
con.executemany("INSERT INTO note (title, body) VALUES (?, ?)", [
    ("Tuning SQLite", "Enable WAL mode and run PRAGMA optimize."),
    ("Indexes", "Covering indexes avoid table lookups when running queries."),
    ("Shopping", "Milk, bread and eggs."),
])
q = "SELECT title FROM note WHERE note MATCH ? ORDER BY rank"
print(con.execute(q, ("run",)).fetchall())          # porter: 'run' matches 'running' and 'run'
# [('Tuning SQLite',), ('Indexes',)]   (order by bm25 score)
print(con.execute(q, ("title:sqlite OR eggs",)).fetchall())
# [('Shopping',), ('Tuning SQLite',)]

print(con.execute('''SELECT highlight(note, 1, '[', ']') FROM note
                     WHERE note MATCH 'wal' ''').fetchone()[0])
# Enable [WAL] mode and run PRAGMA optimize.

user_input = 'C++ "fast"'
safe = '"' + user_input.replace('"', '""') + '"'      # treat as a phrase
print(con.execute(q, (safe,)).fetchall())            # [] and no syntax error

Remember

  • FTS5 virtual tables + MATCH give indexed word search
  • ORDER BY rank sorts by bm25 relevance
  • Quote user input as a phrase to avoid FTS5 syntax errors

Watch out for

  • Passing raw user input to MATCH and crashing on characters like quotes or hyphens.

Check yourself

  1. Why can't LIKE '%term%' replace FTS5 for search?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: sqlite.org/fts5.html

Builds on

Unlocks

Nothing builds on this yet.