PostgreSQL: Full-Text Search: Mistakes
Module: Database-Specific Features
Not creating GIN index on tsvector column
Create GIN index: CREATE INDEX USING GIN (search_vector), 100x faster queries
Without GIN index, PostgreSQL does sequential scan (checks every row). With GIN index, PostgreSQL uses index scan (checks only matching rows). Performance: Without GIN: Sequential scan, 1000ms for 100K rows. With GIN: Index scan, 10ms for 100K rows (100x faster). GIN index is inverted index (maps words to rows). Example: Search "postgresql" without GIN: Check every row (slow). With GIN: Look up "postgresql" in index, get matching rows (fast). Real-world: Medium uses GIN indexes on article search (100x faster). Setup: (1) Add generated column: ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED. (2) Create GIN index: CREATE INDEX idx_search ON table USING GIN (search_vector). Lesson: Always create GIN index for full-text search.
Always create GIN index on tsvector columns. Essential for performance. Use generated columns for better performance than expression indexes.
Critical
Sequential scan (100x slower), poor performance on large tables
Using expression index instead of generated column for tsvector
Use generated column: ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector(...)) STORED
Expression index: CREATE INDEX ON table USING GIN (to_tsvector('english', content)). Problem: Recalculates to_tsvector() on every query (slow). Generated column: ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED. Benefit: Pre-computed tsvector (faster queries). Performance: Expression index: Recalculate on every query (50ms overhead). Generated column: Pre-computed (no overhead). Example: 100K rows, search query. Expression index: 60ms (10ms index + 50ms recalculation). Generated column: 10ms (no recalculation). Real-world: Shopify uses generated columns for product search (faster queries). Migration: (1) Add generated column. (2) Create GIN index on generated column. (3) Update queries to use generated column. Lesson: Use generated columns, not expression indexes.
Use generated columns for tsvector. Pre-computed = faster queries. Expression indexes recalculate on every query (slow).
High
Slower queries (recalculates tsvector on every query), wasted CPU
Using wrong language configuration for text (e.g., english for Spanish text)
Use appropriate language: to_tsvector('spanish', text) for Spanish, to_tsvector('english', text) for English
Language configuration controls stemming and stop words. Wrong language produces bad results. Example: Spanish text "Las bases de datos están corriendo". English config: to_tsvector('english', text) = Wrong stemming (bases → base, not bas), wrong stop words (las not removed). Spanish config: to_tsvector('spanish', text) = Correct stemming (bases → bas), correct stop words (las removed). Result: English config misses searches for "base" (stemmed wrong). Spanish config finds searches for "base" (stemmed correctly). Real-world: Shopify uses language-specific configs for international product search. Multi-language: Use CASE statement in generated column to apply language-specific config based on language column. Lesson: Always use appropriate language configuration.
Use appropriate language configuration. english for English, spanish for Spanish, simple for no stemming. Multi-language: Use CASE statement.
Medium
Bad search results (wrong stemming, wrong stop words), poor relevance
Not using weighted search (title and content treated equally)
Use setweight() to boost title: setweight(to_tsvector(..., title), 'A') || setweight(to_tsvector(..., content), 'B')
Without weights, title and content matches ranked equally. Problem: Content match ranked same as title match (bad UX). With weights, title matches ranked higher. Solution: setweight(to_tsvector('english', title), 'A') || setweight(to_tsvector('english', content), 'B'). Weight A (highest priority), Weight B (lower priority). Example: Search "postgresql". Article 1: "postgresql" in title (weight A). Article 2: "postgresql" in content (weight B). Without weights: Both ranked equally. With weights: Article 1 ranked higher (better UX). Real-world: Medium uses weighted search (title higher priority than content). Lesson: Always use weighted search for better ranking.
Use weighted search. Title weight A (highest), content weight B (lower). Better ranking = better UX.
Medium
Poor ranking (content matches ranked same as title matches), bad UX
Using LIKE operator for search instead of Full-Text Search
Use Full-Text Search: WHERE search_vector @@ to_tsquery(...), 100x faster than LIKE
LIKE operator: WHERE content LIKE ''%word%'. Problems: (1) Sequential scan (slow), (2) No ranking (all results equal), (3) No stemming (misses "words" when searching "word"). Full-Text Search: WHERE search_vector @@ to_tsquery('word'). Benefits: (1) GIN index scan (100x faster), (2) Ranking with ts_rank(), (3) Stemming (finds "words" when searching "word"). Performance: LIKE: Sequential scan, 1000ms for 100K rows. FTS: Index scan, 10ms for 100K rows (100x faster). Example: Search "database". LIKE: Misses "databases" (no stemming). FTS: Finds "databases" (stemming). Real-world: Medium migrated from LIKE to FTS (100x faster). Lesson: Use Full-Text Search, not LIKE.
Use Full-Text Search, not LIKE. 100x faster, ranking, stemming. LIKE only for exact substring matching.
High
Slow queries (sequential scan), no ranking, no stemming