Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
Learn
Learn on SovranCodeCourses5 free learning paths→ExercisesPractice with live challenges→GuidesDirect answers for developers→E-booksFocused field guides→
Build
Build on SovranCodeProjectsPortfolio-ready builds→TemplatesSovranCode team marketplace→Developer toolsFast browser utilities→
Connect
Connect on SovranCodeForumQuestions and discussions→JournalPractical development notes→AboutWhy SovranCode exists→PricingFree, Plus, and Student Plus→
Services
SearchCreate account
Explore SovranCodeLearn, practice, and build.
LearnCourses5 free learning paths→ExercisesPractice with live challenges→GuidesDirect answers for developers→E-booksFocused field guides→
BuildProjectsPortfolio-ready builds→TemplatesSovranCode team marketplace→Developer toolsFast browser utilities→
ConnectForumQuestions and discussions→JournalPractical development notes→AboutWhy SovranCode exists→PricingFree, Plus, and Student Plus→
Search
All articlesTHE SOVRANCODE JOURNAL / POSTGRESQL
PostgreSQL 9 min read

Use PostgreSQL search before adding a search engine

THE SHORT VERSION

Build a useful article search with a GIN index, weighted ranking, and a measured typo fallback—then know what to test before adding another service.

SovranCode EngineeringAugust 11, 2026
Updated September 14, 2026
FROM QUERY TO USEFUL RESULT03 — SEARCH
READER QUERYsemantic formsNot an exact title phrase
01 / PARSEtsqueryterms and operators
02 / RETRIEVEGIN indexmatching documents
03 / ORDERrank + filtertitle first · drafts out
RESULT 01Accessible form labels in semantic HTMLPublished article · title match
The reader sees an answer, not a score. A typo fallback runs separately when normal retrieval is unhelpful.
Jump to a section 7 sections
Start with the search that fails a readerMake one searchable document per articleTurn reader text into ranked resultsTreat typos as a fallback, not a new ranking formulaBuild a small relevance test set before tuningIndex the request you actually sendMove when the product and measurements ask for it

Imagine a small technical library with a few thousand published articles. Someone types “semantic forms” and gets an empty page, even though an article called “Accessible form labels in semantic HTML” explains exactly that. The search box is using ILIKE '%semantic forms%': it expects those two words to sit together, in that order, in one column. Before introducing another index, another deployment, and a synchronization job, we can make the database answer this request properly. This is a worked example for a hypothetical article catalog—not a claim about SovranCode's production search stack.

01 / 07

Start with the search that fails a reader

A substring query is easy to ship. It is also a poor model of what readers mean by search. It does not know that “forms” and “semantic” can be separated by other words; it does not stem common word forms; and ILIKE '%term%' is not, by itself, a ranking strategy. Searching title, summary, and body with three OR conditions gives you a match set, but no principled order. The problem here is not scale. It is that the product has not defined a useful result.

SEARCH DESK / 01

One query, one avoidable dead end

Reader types
semantic forms
Catalog contains
Accessible form labels in semantic HTML
Substring query
No exact contiguous phrase in the title
Useful behavior
Find the article, rank it above a passing body mention, never expose drafts

Write down that last line before touching SQL. Search quality is partly retrieval and partly product policy: a draft must stay hidden; a title hit should usually outrank an incidental body hit; the result page should preserve the reader's query so they can revise it. A more powerful engine cannot supply those decisions for you.

There is another subtlety in this example: the published article says “form labels,” while the reader types “forms.” English text search can reduce those related word forms to a common lexeme. That makes this a better test than merely splitting a phrase across columns. It also explains why the result might differ if you switch to the simple configuration or index content in a language whose stemming rules you have not chosen. Write these expectations into a test set before changing configurations.

02 / 07

Make one searchable document per article

PostgreSQL full-text search works with a tsvector, a normalized document of searchable terms, and a tsquery, the interpreted request. We will store the document as a generated column so updates to title, summary, or body update the search data in the same transaction. Title gets weight A, summary B, body D. Those letters are ranking weights, not access permissions or relevance percentages. We still filter publication status in the query.

WORKED EXAMPLEA minimal article catalog and search documentSQL
CREATE TABLE articles (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title text NOT NULL,
  summary text NOT NULL DEFAULT '',
  body text NOT NULL DEFAULT '',
  status text NOT NULL CHECK (status IN ('draft', 'published')),
  published_at timestamptz,
  search_document tsvector GENERATED ALWAYS AS (
    setweight(to_tsvector('english', title), 'A') ||
    setweight(to_tsvector('english', summary), 'B') ||
    setweight(to_tsvector('english', body), 'D')
  ) STORED
);

CREATE INDEX articles_search_document_gin
  ON articles USING GIN (search_document);

INSERT INTO articles (title, summary, body, status, published_at)
VALUES
  ('Accessible form labels in semantic HTML',
   'Connect labels and inputs without guesswork.',
   'A form is easier to use when every control has a label.',
   'published', now()),
  ('A field guide to page landmarks',
   'Structure a page for navigation.',
   'Semantic HTML also helps readers move around forms.',
   'published', now()),
  ('Unreleased notes on semantic forms',
   'Editorial draft.', 'Not ready for readers.', 'draft', NULL);

Run this in a scratch database. The English configuration is deliberate for this English-only example; choose a suitable configuration or indexing strategy for other languages. The GIN index supports full-text lookup, while status remains an explicit visibility rule.

The generated column avoids an application-side “update the search index too” call that can be forgotten on one edit path. It does not make an article eligible for search: the draft row has a perfectly valid search_document, but the public query must still exclude it. If your content arrives in multiple languages, do not silently stem every document as English. Store language explicitly and design the indexing and query configuration around your actual content. PostgreSQL's table-search guide describes both generated columns and expression indexes.

03 / 07

Turn reader text into ranked results

websearch_to_tsquery accepts search-box text, including familiar quoted phrases, OR, and minus terms. It does not replace SQL parameterization: pass the reader's string as $1, never splice it into query text. @@ asks whether the document matches. ts_rank_cd ranks matches using the weighted document and positional information. A tie-break by publication time and ID makes the order stable between requests.

WORKED EXAMPLEA first search endpoint querySQL
WITH input AS (
  SELECT websearch_to_tsquery('english', $1) AS query
)
SELECT a.id, a.title, a.summary,
       ts_rank_cd(a.search_document, input.query) AS rank
FROM articles AS a
CROSS JOIN input
WHERE a.status = 'published'
  AND a.search_document @@ input.query
ORDER BY rank DESC, a.published_at DESC, a.id DESC
LIMIT 20;

Bind semantic forms as parameter $1. Do not run this for a blank search box; return the normal browse page instead. Some stop-word-only inputs become an empty tsquery, so provide a useful no-results state rather than a mysterious error.

On the three sample rows, the first article should be the prominent result: its title includes a form-related term and semantic HTML. The landmarks article may also match because its body mentions both ideas. The draft must not appear. Do not assert a fixed decimal rank from this illustration; ranking depends on the full indexed text and weighting. Ask whether the order serves the reader, not whether the score looks large.

It is tempting to add published_at directly into the relevance score, or a popularity counter with a convenient multiplier. That quickly creates a mystery number that can bury an exact title match. Keep recency as a tie-break until your query set demonstrates a real need for more. PostgreSQL's text-search controls document the query parser and rank functions; they do not define your product's relevance policy.

A result also needs a stable identity. Return the article ID for linking and use the same publication rule on the destination page. Search filtering alone is not access control: if a draft URL is directly reachable, hiding it from results does not protect it. Keep permissions in the page or API that serves the article, and treat search as a second surface that must honor the same policy.

04 / 07

Treat typos as a fallback, not a new ranking formula

A reader may type “Sematic HTML”. Full-text search will not necessarily repair the misspelled word. PostgreSQL's pg_trgm extension compares three-character sequences and can offer plausible title matches. For this example, use it only after the full-text query returns nothing or too few useful results. Mixing trigram similarity and full-text rank into one uncalibrated score makes the ordering harder to explain and test.

WORKED EXAMPLEInstall a title-only typo fallbackSQL
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX articles_title_trgm_gin
  ON articles USING GIN (title gin_trgm_ops);

-- Bind 'Sematic HTML' as $1, only on the fallback path.
SELECT id, title, summary, similarity(title, $1) AS closeness
FROM articles
WHERE status = 'published'
  AND title % $1
ORDER BY closeness DESC, id DESC
LIMIT 10;

The % operator uses a configurable similarity threshold (0.3 by default). Inspect the suggestions before changing it. Similarity across an entire long title can be weak for a short query, so this is a starting fallback, not a universal autocomplete implementation.

Keep the UI honest: label these as possible matches, retain the original query in the input, and let the reader edit it. Test short queries separately; trigram indexes are not a magical accelerator for every one- or two-character input. For a catalog where people search partial names inside long titles, word_similarity may be more appropriate than whole-title similarity, but that calls for a separate evaluated query and index plan. The pg_trgm documentation covers the operators, thresholds, and supported index classes.

05 / 07

Build a small relevance test set before tuning

Search evaluation does not need to begin with a large benchmark. Collect ten real or representative requests and record the top result a reader ought to see. Include one exact title, one two-term query whose words are separated, one phrase, one misspelling, one term that appears only in a draft, and one query that should return nothing. Then run the actual endpoint, not only the SQL console. If your first five results are odd, adding more indexes will not fix the product decision.

RELEVANCE DESK / 02

A tiny acceptance set

semantic forms
Published form-label article in the first results
"form labels"
Phrase-aware result, if that phrase exists in indexed text
Sematic HTML
Helpful possible match via fallback
unreleased notes
No draft leaks, even if the index contains those words
nonsense query
Clear empty state with an editable search field

Review the list when editors change titles, add content types, or ask for filters. In the UI, show a meaningful title and summary, a link to the article, and the active query. If you later generate highlighted snippets with ts_headline, budget for the extra work and handle the output safely before inserting it into HTML. A good result page is not just a database row rendered in a card.

RELEASE CHECK

Before this search goes live

  1. 01Verify a draft cannot appear in normal or typo-fallback results.
  2. 02Run the acceptance queries against representative content, not only three seed rows.
  3. 03Check the first result on mobile as well as desktop; long titles must not hide the summary.
  4. 04Handle blank, stop-word-only, no-result, and misspelled queries explicitly.
  5. 05Record a baseline for query latency and the relevance failures users report.
06 / 07

Index the request you actually send

The GIN index helps find documents matching @@, but it does not make every part of the request free. PostgreSQL still applies visibility filters, calculates rank for candidates, and orders results. If a category filter becomes common, measure the full query with that condition instead of declaring the search solved because an index exists. A partial index or other index design might help a real workload, but the right choice depends on row counts, data distribution, and writes.

WORKED EXAMPLEInspect the actual planSQL
ANALYZE articles;

EXPLAIN (ANALYZE, BUFFERS)
WITH input AS (
  SELECT websearch_to_tsquery('english', 'semantic forms') AS query
)
SELECT a.id, a.title,
       ts_rank_cd(a.search_document, input.query) AS rank
FROM articles AS a CROSS JOIN input
WHERE a.status = 'published'
  AND a.search_document @@ input.query
ORDER BY rank DESC, a.published_at DESC, a.id DESC
LIMIT 20;

Use representative volume for performance conclusions. A sequential scan on this three-row teaching fixture is normal—the planner may reasonably avoid an index when reading the whole table is cheaper.

EXPLAIN (ANALYZE, BUFFERS) runs the query and reports actual timing and buffer activity. Compare plans when the corpus has realistic row counts and term frequencies. Check both a common word that matches many articles and a rare term that matches one. Monitor the search endpoint's tail latency in the application too; a fast database plan does not tell you whether the page, network, or rendering feels fast. The EXPLAIN guide explains why estimates and actual rows can differ.

07 / 07

Move when the product and measurements ask for it

A dedicated search service is useful when you have a concrete requirement that this implementation handles poorly: rich faceting across several content types, sophisticated autocomplete and spelling suggestions, language-specific analysis, personalized ranking, or latency targets that the measured PostgreSQL workload cannot meet without unacceptable tradeoffs. “We have a few thousand articles” is not, by itself, a migration trigger. Nor is “PostgreSQL can do search” proof that it should own every search feature forever.

If you migrate, keep the acceptance queries. Run them against both implementations and compare the order people see, not just response time. Write down how publication status, edits, and deletions reach the new index, and what readers see when synchronization lags. A second search system introduces a consistency boundary: a draft published in the database may not be searchable yet, or a withdrawn article might linger if updates fail. Those are manageable problems, but they belong in the decision.

Plan the rollback at the same time as the migration. If the new service is unavailable, can the product temporarily return to a simpler PostgreSQL result set? If not, what message does the reader see? Do not promise an invisible failover if relevance, filters, or freshness would change. A deliberate degraded mode—with an honest label and a working path to content—is better than a spinner that never completes.

The useful starting point is smaller: one weighted document, one parameterized full-text query, one explicit publication filter, a narrow typo fallback, and a set of reader queries you can inspect. You can improve relevance from there—or justify a separate engine with evidence instead of instinct.

IN THIS ARTICLE
Start with the search that fails a readerMake one searchable document per articleTurn reader text into ranked resultsTreat typos as a fallback, not a new ranking formulaBuild a small relevance test set before tuningIndex the request you actually sendMove when the product and measurements ask for it
1,859 words7 sections
KEEP READING

More from SovranCode

ARTICLESemantic HTML that scales beyond the first pageARTICLEDesign a practice loop that actually teaches coding
READ NEXTDesign a practice loop that actually teaches coding
THE SOVRANCODE PLATFORM

Learn enough to build something real.

Start learning
100Learning modules
39exercises
6projects
2templates
4E-books
4guides
SovranCode

A free-first programming platform for people who learn best by understanding, practicing, and building.

● Core learning content is free
FollowYouTubePinterestX
LearnHTML courseCSS courseJSJavaScript coursePython courseC courseSQL courseDeveloper guidesAll exercises
BuildProjectsTemplate marketBuyer libraryTemplate licensesDeveloper toolsE-books
CommunityForumJournalContact
CompanyPricing previewAboutServicesPrivacyEnglish edition. Additional languages will be published only after full editorial review.
© 2026 SovranCode · Built for curious minds.Next.js · Node.js · PostgreSQL