Full-Text Search
Search prose columns with stemming, ranking, and highlighting — using tsvector, tsquery, and GIN indexes
Both the tv_db and beer_db have pre-computed tsvector columns: episodes and shows have description_tsv; beers have description_tsv; beer_ratings have review_tsv. These are GENERATED ALWAYS AS columns — PostgreSQL keeps them in sync automatically when the source text changes.\n\nFull-text search differs from LIKE in two important ways. First, it stems words: searching for "brewed" also matches "brew", "brewing", "brews". Second, it ranks results by relevance rather than just returning a binary match/no-match.\n\nIn this lab you will search episode descriptions for thriller-adjacent keywords, search beer reviews for positive sentiment words, and rank and highlight the results — all using the GIN-indexed tsvector columns that already exist in both databases.
tsvector: The Indexed Document
A tsvector is a sorted list of lexemes (normalised, stemmed word roots) with their positions. It is stored pre-computed so queries do not need to parse the text on every search.
SELECT to_tsvector('english', 'An intensely hoppy IPA bursting with tropical fruit aromas.');
-- 'aroma':7 'burst':5 'fruit':6 'hopp':3 'intensely':2 'ipa':4 'tropical':6
tsquery: The Search Expression
-- plainto_tsquery: splits on spaces, ANDs the terms, handles stemming
SELECT plainto_tsquery('english', 'tropical fruit'); -- 'tropical' & 'fruit'
-- to_tsquery: explicit operators (& AND, | OR, ! NOT, <-> phrase)
SELECT to_tsquery('english', 'hop & bitter & !sweet');
SELECT to_tsquery('english', 'chocolate <-> stout'); -- phrase: chocolate followed by stout
@@ Match Operator
-- Search beer descriptions for tropical fruit
SELECT name, description
FROM beers
WHERE description_tsv @@ plainto_tsquery('english', 'tropical fruit')
LIMIT 5;
Ranking with ts_rank
SELECT name,
ts_rank(description_tsv, plainto_tsquery('english', 'tropical fruit')) AS rank
FROM beers
WHERE description_tsv @@ plainto_tsquery('english', 'tropical fruit')
ORDER BY rank DESC
LIMIT 5;
Highlighting with ts_headline
SELECT name,
ts_headline('english', description,
plainto_tsquery('english', 'tropical fruit'),
'MaxFragments=1,MaxWords=15,MinWords=5') AS snippet
FROM beers
WHERE description_tsv @@ plainto_tsquery('english', 'tropical fruit')
LIMIT 5;
ts_headline wraps matched terms in <b>...</b> tags by default.
tsvector
A sorted list of stemmed lexemes with position information, stored pre-computed in a column. to_tsvector() parses raw text into a tsvector, applying the language's stop-word list and stemming rules. When stored in a GIN-indexed column, FTS queries against it are index scans — not sequential text scans.
tsquery
A parsed search expression that can include AND (&), OR (|), NOT (!), and phrase proximity (<->). plainto_tsquery() converts a plain phrase into a tsquery by splitting on spaces and ANDing the terms. to_tsquery() lets you write explicit operators for more control.
ts_rank and ts_headline
ts_rank() scores a tsvector against a tsquery based on how many terms match, their frequency, and their positions. Higher scores mean more relevant matches. ts_headline() returns a short snippet of the original text with matching terms highlighted — useful for displaying search result previews.
🔌 Connect to tv_db
Connect to tv_db as the tv_db user. Full-text search exercises start with the shows and episodes tables, which have pre-computed description_tsv columns.
psql -U tv_db -d tv_dbpsql (18.4) Type "help" for help. tv_db=>
🔎 Search Show Descriptions with plainto_tsquery
Find shows whose description contains words related to "corporate conspiracy" using plainto_tsquery. plainto_tsquery splits the phrase and ANDs the terms with automatic stemming — no need to worry about "conspired" vs "conspiracy".
SELECT title, status, description FROM shows WHERE description_tsv @@ plainto_tsquery('english', 'corporate conspiracy') ORDER BY title;title | status | description ---------------+--------+--------------------------------------------------- Neon District | ended | In 2077, a hacker exposes a corporate conspiracy. (1 row)
🎯 Boolean Search with to_tsquery (OR)
Find shows related to either "investigation" or "detective" using to_tsquery with the | (OR) operator. This returns shows matching either term — more results than AND.
SELECT title, status, ts_rank(description_tsv, to_tsquery('english', 'investigation | detective')) AS rank FROM shows WHERE description_tsv @@ to_tsquery('english', 'investigation | detective') ORDER BY rank DESC LIMIT 8;title | status | rank -----------------+-----------+------------- Clockwork City | cancelled | 0.06079271 Crimson Shore | ended | 0.06079271 Pale Fire | ended | 0.030396355 Apex Predator | ongoing | 0.030396355 City of Signals | ongoing | 0.030396355 Dark Harbour | ended | 0.030396355 The Fold | hiatus | 0.030396355 Stone & Shadow | ended | 0.030396355 (8 rows)
✂️ Highlight Matches with ts_headline
For shows matching "detective" or "investigation", add ts_headline to show a short snippet of the description with the matching words highlighted.
SELECT title, ts_headline('english', description, to_tsquery('english', 'investigation | detective'), 'MaxFragments=1, MaxWords=12, MinWords=5') AS snippet FROM shows WHERE description_tsv @@ to_tsquery('english', 'investigation | detective') ORDER BY title LIMIT 5;title | snippet -----------------+----------------------------------------------------------------------------------- Apex Predator | <b>investigative</b> journalist hunts a serial killer who hunts journalists City of Signals | Near-future <b>detective</b> uses augmented reality overlays to solve murders Clockwork City | steam-powered metropolis, a <b>detective</b> <b>investigates</b> automaton crimes Crimson Shore | Coastal town murders <b>investigated</b> by a disgraced <b>detective</b> Dark Harbour | Gritty <b>detective</b> series set in a fictional port city (5 rows)
🔀 Switch to beer_db and Search Reviews
Disconnect from tv_db and connect to beer_db as the beer_db user. Then search beer_ratings.review_tsv for reviews mentioning "aroma" to warm up for the next step.
\qpsql -U beer_db -d beer_dbpsql (18.4) Type "help" for help. beer_db=>
⭐ Rank Beer Reviews by Keyword Relevance
Search beer_ratings for reviews mentioning "tropical" or "aroma". Join to beers to show the beer name. Rank by ts_rank and show the top 8 results. Use a subquery to resolve the beer name — no manual ID lookup needed.
SELECT b.name AS beer_name, b.style, br.reviewer_name, ROUND(br.score, 1) AS score, ts_rank(br.review_tsv, to_tsquery('english', 'tropical | aroma')) AS rank, br.review_text FROM beer_ratings br JOIN beers b ON b.id = br.beer_id WHERE br.review_tsv @@ to_tsquery('english', 'tropical | aroma') ORDER BY rank DESC LIMIT 8;beer_name | style | reviewer_name | score | rank | review_text -----------------+---------------------+-------------------+-------+------------+---------------------------------------------------------------------------------- Wild Weizen | American Wheat Beer | Kenji Watanabe | 6.3 | 0.06079271 | The aroma alone is worth it — tropical fruits and pine resin in perfect harmony. Wild Saison | Munich Helles | Fernanda Rocha | 8.7 | 0.06079271 | Poured a beautiful hazy golden colour. The tropical hop aroma was intoxicating. Roasty Gose | Blonde Ale | Rafael Cavalcanti | 8.1 | 0.06079271 | The aroma alone is worth it — tropical fruits and pine resin in perfect harmony. Premium Weiss | Belgian Tripel | Ashley Pinder | 4.5 | 0.06079271 | Poured a beautiful hazy golden colour. The tropical hop aroma was intoxicating. Anniversary Ale | Rauchbier | Sophie Leclercq | 8.3 | 0.06079271 | The aroma alone is worth it — tropical fruits and pine resin in perfect harmony. Session IPA | Weissbier | Hans Müller | 8.6 | 0.06079271 | The aroma alone is worth it — tropical fruits and pine resin in perfect harmony. Hazy Weiss | Dunkelweizen | Marek Wiśniewski | 4.1 | 0.06079271 | Poured a beautiful hazy golden colour. The tropical hop aroma was intoxicating. Session Saison | Munich Helles | Rafael Cavalcanti | 8.9 | 0.06079271 | The aroma alone is worth it — tropical fruits and pine resin in perfect harmony. (8 rows)
Lab complete! You can now build full-text search queries over tsvector columns:\n\n\n col @@ plainto_tsquery('english', phrase) : ✅ natural language match\n col @@ to_tsquery('english', 'a | b & !c'): ✅ boolean keyword logic\n ts_rank(col, query) : ✅ relevance score\n ts_headline(lang, text, query, options) : ✅ highlighted snippet\n GIN index on tsvector column : ✅ makes @@ an index scan\n
Enable JavaScript to run the live terminal and track your progress.