Searching text
In short: Full-text search finds words in text through an inverted index that stores, for each word, the rows that contain it, instead of reading everything with LIKE.
Problem
The search box
The site gets a search box for the reviews. The first idea is LIKE. A customer looks for "ice":
It finds "nicely", because LIKE looks for a piece of the text, not a word. Another search:
Nothing, although there is a review about the knives: the text says "knives", not "knife". And % at the start rules out an index: with a million reviews the database reads a million texts for every search.
More precisely, if you are curious
In SQLite LIKE ignores the difference between capital and small letters only for the Latin alphabet. In German LIKE '%öl%' misses "Öl" (oil), and the same happens in Greek or Bulgarian. The full-text index below makes all letters small, including those.
The idea
The inverted index
Full-text search does the opposite of the table. The table says "review 3 contains the words the, best, coffee, in, the, neighbourhood". The inverted index says "the word coffee is in reviews 3, 6 and 13". Searching for a word is one read in the index, not a pass through the texts.
The text is first split into words (a tokenizer). unicode61 cuts at spaces and punctuation and makes all letters small, in every alphabet. SQLite keeps the index in a virtual table with fts4:
docid is the number of the row in the index. Here it is the number of the review, so that it joins with reviews. MATCH is the search in the index, not = or LIKE. And "nicely" is not found, because "ice" is not a word in it.
This is what the words in the index look like (fts4aux is a table that shows the words and how many documents each is in):
The idea
Word forms
In the index "knife" and "knives" are two different words. The simplest help is a prefix: kni* means any word that starts with "kni":
A prefix is crude: kni* would also find "knitted". Real search engines have analysers for each language that reduce words to their stem (stemming): "tomatoes" becomes "tomato". SQLite has one only for English (tokenize=porter), and Elasticsearch and OpenSearch have them for many languages. Irregular forms like "knives" trip up even those.
Try it
Tomatoes
Find all the reviews about tomatoes, whatever the form of the word, in the index (columns docid and body).
Solution
SELECT docid, body FROM review_search WHERE review_search MATCH 'tomato*';MATCH 'tomato*'. Just 'tomato' finds nothing: in the texts the word is "tomatoes".
The idea
More than one word
Two words one after the other mean AND: the review must have both. OR means at least one of them:
A phrase in quotes looks for the words next to each other and in that order. NEAR/3 looks for the two words at most 3 words apart:
NOT removes the documents with a given word: coffee NOT date.
More precisely, if you are curious
FTS4 has two query syntaxes. In the standard one "without the word" is written with a minus (coffee -date), and in the enhanced one with NOT. sql.js, which runs SQLite in the browser, uses the enhanced one, and there -date is simply the word "date". The enhanced one also allows brackets: (apples OR tomatoes) NOT torn. In Elasticsearch the same is a bool query with must, should and must_not.
Try it
Coffee without complaints
Find the reviews about coffee that do not mention a date (columns docid and body).
Solution
SELECT docid, body FROM review_search WHERE review_search MATCH 'coffee NOT date';MATCH 'coffee NOT date'. With -date in sql.js you search for the reviews that have both coffee and date, that is exactly the opposite.
The idea
Snippets and order
A search engine shows a snippet with the word marked. snippet does it on its own:
The first results should be the most relevant. Real search engines order them by BM25: the rarer the word across all the documents and the more often it is in this one, the higher, and a short text with the word beats a long one. The table fts4aux from the card on the index gives the numbers for that: how many documents each word is in. FTS4 does not order on its own (FTS5 and Elasticsearch have BM25 built in), so here the order comes from ORDER BY.
Exercise
Searching with the products
1. The index keeps only the text. The name of the product and the rating are in the tables.
Find the reviews about coffee with the name of the product and the rating (columns name, rating and body), from the highest rating down.
Solution
SELECT p.name, r.rating, r.body
FROM review_search s
JOIN reviews r ON r.id = s.docid
JOIN products p ON p.id = r.product_id
WHERE review_search MATCH 'coffee'
ORDER BY r.rating DESC;JOIN reviews r ON r.id = s.docid, then JOIN products, and MATCH 'coffee' in WHERE. That is why docid is the number of the review. ORDER BY r.rating DESC puts the best first; without DESC the review with rating 1 comes first.
2. The index is a separate table and does not know on its own when reviews changes. A trigger updates it on every new review:
Write a trigger review_removed that removes the review from review_search when it is deleted from reviews.
Solution
CREATE TRIGGER review_removed AFTER DELETE ON reviews
BEGIN
DELETE FROM review_search WHERE docid = old.id;
END;AFTER DELETE ON reviews and DELETE FROM review_search WHERE docid = old.id. On a delete there is only the row old: new exists for INSERT and UPDATE. The check deletes review 13 and searches again: it must not come up. For completeness you also need a trigger for an UPDATE of the text.
The idea
When a separate search engine
For searching a few thousand or million texts the full-text index of the database is enough: FTS in SQLite, tsvector and a GIN index in PostgreSQL, FULLTEXT in MySQL. A separate search engine (Elasticsearch, OpenSearch, Meilisearch, Typesense) pays off when search is the main job of the site: analysers for many languages, fixing typos ("cofee" → "coffee"), filters by category with counts next to them, suggestions while you type, and many machines. Then the data is copied into it, usually from an event stream, and the truth stays in the database.
More precisely, if you are curious
In PostgreSQL the same search is WHERE to_tsvector('english', body) @@ to_tsquery('english', 'coffee') with the index CREATE INDEX ON reviews USING gin (to_tsvector('english', body)). In Elasticsearch the field gets "analyzer": "english" and the search is {"query": {"match": {"body": "coffee"}}}.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: INSERT: new rows · DELETE: deleting rows · Triggers · Indexes
- Continues in: Searching by meaning · How to choose a database
- Tasks on this topic: Kinds of databases