Back to Blog

Search that finds typos, synonyms and exact terms: one Postgres table, one Spring Boot app

Built in plain PostgreSQL 18 first, then driven from Spring Boot 4.1 through the JPA Criteria API, up to the exact line where Criteria runs out and one native query takes over. Runnable repo, 36 tests against real Postgres.

Lukas GrigisSoftware Architect22 min read
0:00 / 0:00
postgresspring-bootfull-text-searchsearchjava
One database table drawn as a single slab with three labelled query paths entering it and one ranked list leaving. Postgres full-text search from Spring Boot.
On this page

Key takeaways

  • Three retrievers run over one Postgres table: a stored tsvector for exact terms, pg_trgm trigrams for typos, and a native synonym dictionary for the vocabulary gap. No search engine and nothing to keep in sync.
  • Hibernate renders an unregistered cb.function call for ts_rank straight through into the SQL and lets Postgres decide. Only the @@ and % operators need FunctionContributor.registerPattern, because an operator has no function-call form to fall through to.
  • A synonym pair listed in both directions is self-cancelling. The substitution runs at index time as well as at query time, so both words swap to the other one and still never meet.
  • The three retrievers compose as Spring Data Specifications. Fusing them does not: Reciprocal Rank Fusion needs CTEs, ROW_NUMBER() and a FULL OUTER JOIN, none of which the Criteria API can express. That is one native query, and the boundary stays visible.

Search Stevensen and you get Robert Louis Stevenson. Search casement and you get six books, not one of which contains the word. Same table. Same endpoint. No search engine.

Here's that second query, live against the demo:

json|GET /api/search?q=casement&mode=SYNONYM
{
  "content": [
    {
      "title": "The Picture of Dorian Gray",
      "headline": "<mark>window</mark>, producing a kind of momentary
                   Japanese effect, and making him think of those pallid",
      "score": 0.06079271
    },
    {
      "title": "Lady Windermere's Fan",
      "headline": "<mark>window</mark> doing fancy work, and making
                   ugly things for the poor, which I think so useful",
      "score": 0.06079271
    }
  ],
  "page": { "totalElements": 6 }
}

Six books come back. The highlighted word is window, which is not what I typed, and a SELECT count(*) FROM book WHERE excerpt ILIKE '%casement%' over the whole corpus returns 0.

Those <mark> tags are ts_headline, and it does more work here than anything else on the page. Hand it the same tsquery you matched with and it returns an excerpt with the matching lexemes wrapped in whatever you set as StartSel and StopSel. I ask for <mark>, so the UI gets semantic HTML rather than a class name I'd have to style. It's also the reason a synonym match is believable instead of magic: you can see that the word it found is window.

Postgres does all three kinds of search on its own: exact terms through a stored tsvector that Spring Boot never has to maintain, typos through pg_trgm trigrams, and the vocabulary gap through a native synonym dictionary. You can reach every one of them from the JPA Criteria API, compose them as Spring Data Specification chains, and fuse all three into a single ranked list with Reciprocal Rank Fusion. There's exactly one place where the Criteria API runs out and you drop to native SQL.

None of this is new, and that's the point. Full-text search has been in core Postgres since 8.3 and pg_trgm is older still. I built this on PostgreSQL 18 and Spring Boot 4.1 because that's what I run today, not because either version is what makes it work.

One table, three ways in

I wanted a demo where nothing is hidden, run the same way as the Spring OAuth2 series: one command, one public repo, no hand-waving. So: one book table, 77 public-domain classics pulled from Project Gutenberg, author names that invite typos, and real work text in the excerpts, not blurbs. Three retrievers read that one table.

Each retriever is a different answer to "what counts as a match":

  • A tsvector is a document after processing, stored as a sorted list of normalized word roots called lexemes. to_tsvector('english', 'The cats were running') gives 'cat':2 'run':4.
  • A tsquery is a search condition over those lexemes. The @@ operator asks whether a tsvector satisfies a tsquery, and that is the entire matching model.
  • A trigram is a three-character run of a string. pg_trgm compares the sets of trigrams in two strings and returns a similarity between 0 and 1, which is how it reaches a misspelling.
  • A synonym dictionary maps one lexeme to another while a document is being parsed, so a query word can match a document that never contains it.
Three paths in, one store, one ranked list out. Blue composes in Java, amber is the single native query, and the list on the right is what fusing all three produces.

Everything in this half of the post is plain SQL. Every result block below comes from support/demo.sql, which mise run sql runs against the seeded database with psql -e so each statement is echoed above its own output. The schema statements come from the Liquibase changelog. No application is involved in any of it.

The three retrievers catch different things, and the differences are not subtle:

RetrieverCatchesMissesIndexCost
Lexicalstemmed words, phrases, negationmisspellings, unshared vocabularyGIN on the stored tsvectorindex scan at any size
Fuzzytypos, misspelt names, near-matchesmeaning; whale will not find seaGIN gin_trgm_ops, unused herescans both tables, the OR spans two
Synonyma word the document never containsinflected forms of a dictionary keyGIN on a functional expressionindex scan, but the expression must match

How does Postgres full-text search rank results?

Postgres full-text search from Spring Boot starts with one stored tsvector. Four steps get you a ranked result set, and none of them needs application code:

  • Store the vector in a generated column, so Postgres recomputes it only when the row changes.
  • Index that column with GIN, which is the lookup @@ performs.
  • Weight the fields with setweight, labelling title A and excerpt B.
  • Order by ts_rank_cd.

A title match then outranks an excerpt match on its own, because the ranking functions weight those setweight labels differently by default.

sql|001-schema.xml, changesets 006 and 007
ALTER TABLE book
    ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
        setweight(to_tsvector('english', COALESCE(excerpt, '')), 'B')
        ) STORED;

CREATE INDEX idx_book_search_vector ON book USING GIN (search_vector);

That STORED on line 5 is not decoration. PostgreSQL 18 changed the default for generated columns to VIRTUAL, and the release notes say so plainly: "Allow generated columns to be virtual, and make them the default [...] The write behavior can still be specified via the STORED option."1 A virtual column is computed on read, and a column computed on read cannot be indexed. Leave the keyword off and the CREATE INDEX in the next changeset fails:

text
ERROR: indexes on virtual generated columns are not supported

So on Postgres 18 the missing keyword breaks your index, not your column, which is a confusing way to find out. It's the one PG 18 change this repo actually reproduces, in a test that creates a virtual column and watches the index fail.

One asymmetry to know before it puzzles you: ts_headline only ever scans the excerpt, so a title-only match like time finding Hard Times comes back with no <mark> at all. HeadlineIT scopes its marking assertion to rows whose excerpt contains the word, with a comment naming the case as a real gap.

Parsing what a person actually types

Four functions turn text into a tsquery and only one of them is safe to hand a search box:

FunctionOn foo barRaw input safe?
to_tsquerysyntax error, it wants operatorsno, it throws
plainto_tsquery'foo' & 'bar'yes, minus phrases and negation
phraseto_tsquery'foo' <-> 'bar'yes, but all one phrase
websearch_to_tsquery'foo' & 'bar'yes

websearch_to_tsquery accepts the syntax people already type into search engines, and it never throws on malformed input:

sql
SELECT websearch_to_tsquery('english', 'whale "white sea" -captain');
-- 'whale' & 'white' <-> 'sea' & !'captain'

Quoted phrases become the followed-by operator, a leading - becomes negation, and a stray bracket becomes nothing at all. That's the constructor you can wire to a UI and stop worrying about.

ts_rank and ts_rank_cd disagree, so you have to choose

ts_rank weights by term frequency. ts_rank_cd computes cover density, which rewards documents where the query terms sit close together. On a real corpus they genuinely reorder the same match set. Here is the query old time, scored both ways, showing the top five of the 11 matching books:

text
 id |                     title                      | ts_rank | ts_rank_cd
----+------------------------------------------------+---------+------------
 34 | The Adventures of Tom Sawyer, Complete         | 0.63776 |    0.29379
 35 | A Connecticut Yankee in King Arthur's Court    | 0.30459 |    0.05714
 20 | Bartleby, the Scrivener: A Story of Wall-Street | 0.00863 |    0.02105
 21 | Billy Budd : $b and other prose pieces         | 0.00117 |    0.01600
  3 | Romeo and Juliet                               | 0.00080 |    0.02808

Order by frequency and positions three to five are 20, 21, 3 (the $b in the fourth title is a MARC subfield marker riding along in Gutenberg's own metadata: real corpus, real mess, left in). Order by cover density and those three become 3, 20, 21. Romeo and Juliet mentions the terms rarely but close together, so proximity promotes it two places, past both Melville entries. Neither ordering is correct in the abstract. Both numbers are only meaningful relative to other rows for the same query, which is worth remembering before anyone asks you what a good score is.

I use ts_rank_cd for the lexical retriever and ts_rank for the synonym one, and a test asserts the two disagree on this corpus. Same habit as the virtual threads post: if a claim can be measured, measure it, because the corpus that makes two ranking functions agree would quietly turn that choice into decoration.

How do you make Postgres full-text search tolerate typos?

You reach outside full-text search for this one, to pg_trgm, because the stemmer structurally cannot help you. Stevensen and Stevenson stem to different lexemes, so @@ will never connect them no matter which configuration you use. Trigram similarity ignores language entirely and scores that pair at 0.2692, comparing three-character runs instead of word roots. Match with the % operator, which a gin_trgm_ops index can answer, and rank with similarity(), which it cannot.

Watch the stemmer fail and the trigrams succeed, on the same word, in the same session:

sql|psql -e, against the seeded corpus
SELECT b.title FROM book b
 WHERE b.search_vector @@ websearch_to_tsquery('english', 'Stevensen');

 title
-------
(0 rows)

SET pg_trgm.similarity_threshold = 0.2;
SET

SELECT b.title, round(similarity(a.name, 'Stevensen')::numeric, 4) AS sim
  FROM book b JOIN author a ON a.id = b.author_id
 WHERE a.name % 'Stevensen'
 ORDER BY sim DESC, b.id;

                    title                    |  sim
---------------------------------------------+--------
 Treasure Island                             | 0.2692
 The strange case of Dr. Jekyll and Mr. Hyde | 0.2692
 Kidnapped                                   | 0.2692
(3 rows)

That SET in the middle is doing real work. % compares against pg_trgm.similarity_threshold, which defaults to 0.3, and similarity('Robert Louis Stevenson', 'Stevensen') is 0.2692. Leave the default in place and all three rows vanish. Spelled correctly, the same pair scores 0.4348.

The predicate and the ranking also use different tools on purpose. % is the match, similarity() is the score, and they are not interchangeable: a gin_trgm_ops index can answer % and cannot answer similarity() >= 0.2. I set the threshold to 0.2, and where I set it turns out to matter later.

The operator class you pick decides what else the index can do. gin_trgm_ops answers % and LIKE. gist_trgm_ops answers those too and adds something GIN cannot do at all: ORDER BY name <-> :q, a nearest-neighbour scan that walks the index in closest-first order. If you ever want "did you mean" ranked straight out of the index instead of sorted afterwards, GiST is the one that gives it to you. This demo doesn't need it.

One honest note while you're here: this particular predicate ORs across book and author, and no single-table index can answer an OR that spans two tables. So fuzzy scans both, at any size. At 77 rows that's free. It's the first thing I'd rethink at scale.

Three statements and a text file, no application code. You create a dictionary with TEMPLATE = synonym over a source target file, clone the built-in configuration with COPY = english, and then ALTER MAPPING ... WITH book_synonym, english_stem so every token hits your dictionary before it reaches the stemmer. List each pair in one direction only, because the substitution runs at index time as well as at query time:

sql|001-schema.xml, changeset 008
CREATE TEXT SEARCH DICTIONARY book_synonym (
    TEMPLATE = synonym, SYNONYMS = book_synonym
);

CREATE TEXT SEARCH CONFIGURATION book_synonym_search (COPY = english);

ALTER TEXT SEARCH CONFIGURATION book_synonym_search
    ALTER MAPPING FOR asciiword, asciihword, hword_asciipart, word, hword, hword_part
    WITH book_synonym, english_stem;

Six pairs, one per line, and the file has to sit in the server's $SHAREDIR/tsearch_data before that first statement runs:

text|support/tsearch_data/book_synonym.syn (comment header omitted)
avenue street
brook river
casement window
chamber room
apothecary doctor
vessel ship

That third line is the whole casement demo, and it needs no application to work:

sql|psql -e, the hook query with nothing but Postgres involved
SELECT b.title FROM book b
 WHERE to_tsvector('book_synonym_search', b.title || ' ' || b.excerpt)
       @@ websearch_to_tsquery('book_synonym_search', 'casement');

                 title
----------------------------------------
 The Picture of Dorian Gray
 Lady Windermere's Fan
 Wuthering Heights
 The Secret Agent: A Simple Tale
 The Invisible Man: A Grotesque Romance
 Anne of Green Gables
(6 rows)

Every target word occurs somewhere in the 77 books while no source word does, which is deliberate: a pair whose target appears nowhere silently matches nothing, and a feature that silently matches nothing looks exactly like a broken one. A test fails the build if the file and the corpus ever drift apart.

Only the dictionary's exact key gets there before the stemmer, so apothecaries never reaches the apothecary entry and synonym mode is not a superset of lexical mode. The documented fix is a thesaurus dictionary, which I left out on purpose: the synonym template is what makes the index-time symmetry below visible, and that is the thing worth seeing.

Write each pair once, in one direction

My first version of that file listed every pair both ways. It looked right. Symmetry, so either word finds the other.

It matched nothing.

Now the reason. The substitution runs at index time as well as at query time, because indexing and querying both go through the same text search configuration. With sleuth detective and detective sleuth both present, a document saying "the detective solved it" indexes as sleuth, and a query for sleuth is rewritten to detective. Both sides moved. They swapped past each other.

A perfectly symmetric dictionary is a no-op that costs you the feature.

Delete one direction and both queries start working, which is the part that reads as backwards until you see it. With only detective sleuth, the document still indexes as sleuth and the query sleuth is not a key, so it stays sleuth. Both ends converge on the same lexeme. The manual documents the dictionary and the mapping; it does not spell out this consequence, so the test that builds two scratch dictionaries and shows both behaviours is the only citation I have for it.

Can you call Postgres full-text search functions from the JPA Criteria API?

Everything so far happened inside Postgres. Three retrievers, one table, a dictionary file and a psql prompt. No Java has appeared yet, and none of it needed any.

Now the app, which is the part nobody seems to have written down. Yes, you can reach all of it from the Criteria API, and this is where I expected the work to be. It wasn't. Hibernate renders an unregistered cb.function("ts_rank", …) straight through into the SQL as ts_rank(args) and lets Postgres resolve it. Hibernate never validates the function name. It renders it and lets the database decide. So ts_rank, ts_rank_cd, ts_headline, similarity, to_tsvector and websearch_to_tsquery all work with no registration whatsoever, and the whole suite stays green without any of them declared.

But @@ and % are different in kind. They're operators, so there is no fts(a, b) for Hibernate to fall through to, and the query fails at the database. Those two are the only things that need teaching:

java|SearchFunctionContributor.java (condensed: javadoc and imports omitted)
public class SearchFunctionContributor implements FunctionContributor {

    private static final int ORDINAL = 1000;

    @Override
    public void contributeFunctions(FunctionContributions functionContributions) {
        SqmFunctionRegistry registry = functionContributions.getFunctionRegistry();
        BasicTypeRegistry basicTypes =
                functionContributions.getTypeConfiguration().getBasicTypeRegistry();

        registry.registerPattern("fts", "?1 @@ ?2",
                basicTypes.resolve(StandardBasicTypes.BOOLEAN));

        // pg_trgm's similarity operator. Same operator the native RRF query's
        // fuzzy CTE uses, so both share pg_trgm.similarity_threshold and a
        // typo found by FUZZY survives into FUSED.
        registry.registerPattern("trgmSimilar", "?1 % ?2",
                basicTypes.resolve(StandardBasicTypes.BOOLEAN));
    }

    @Override
    public int ordinal() {
        return ORDINAL;
    }

}

Two lines of registration for a whole search stack. Register it through META-INF/services/org.hibernate.boot.model.FunctionContributor, which Hibernate reads while building the SessionFactory, before the Spring context exists. That's why the class isn't a bean and can't be injected into. The ordinal() override is belt and braces, since the interface already defaults to 1000; spelling it out documents which range this class belongs in.

Note the comment above trgmSimilar. Registering % here is what keeps the Criteria predicate and the native query on the same threshold, and skipping it cost me something later on.

If you go looking for this, most of what you'll find teaches MetadataBuilderContributor instead. That interface is @since 5.3 and ships in Hibernate 7.4.1 annotated @Deprecated(forRemoval = true). FunctionContributor is the SPI Hibernate 7 picks up, and it has been in Hibernate since 6.

With those two registered, each retriever is an ordinary Specification. The fuzzy one is the whole of it:

java|BookSearchExpressions.java, the FUZZY case
case FUZZY -> (root, cq, cb) -> cb.or(
        cb.isTrue(similarEnough(cb, root.get("title"), query)),
        cb.isTrue(similarEnough(cb, root.get("author").get("name"), query))
);

similarEnough is the trgmSimilar pattern from above, so that cb.or renders as title % :q OR author.name % :q: the same % you saw in psql, now on both columns, same threshold, reached through Specification.and and composable with a genre filter.

The Criteria route buys you one more thing on the way out: the query selects straight into a record with cb.construct, so the score and the highlighted snippet arrive as fields on a real type. That is also why I build the CriteriaQuery by hand instead of going through JpaSpecificationExecutor. Score and headline are computed SQL expressions, not mapped attributes, and a Spring Data projection has nothing on the entity to read them from.

One dead end worth saving you the trip. Spring Data JPA 4 also ships a newer PredicateSpecification alongside the classic Specification, and I checked whether it replaces it for ranked search. It can't: its toPredicate never receives the CriteriaQuery, and ORDER BY lives on the CriteriaQuery, so the newer interface structurally cannot rank.2 The classic three-argument Specification is the right choice here, not the newer one.

Fusing three retrievers with RRF in one query

Reciprocal Rank Fusion combines ranked lists by throwing away the scores and keeping only the positions. RRF scores a document as the sum of 1 / (k + rank) over every retriever that returned it. That sidesteps the real problem, which is that a ts_rank_cd of 0.08 and a trigram similarity of 0.62 mean nothing to each other.

The constant k is 60, straight from the paper that introduced the method. Cormack, Clarke and Buettcher say they fixed it "during a pilot investigation and not altered during subsequent validation", and report that it turned out to be near-optimal, though "the choice was not critical".3 I left it untuned, because inventing a different number would mean claiming an evaluation I never ran.

The note along the bottom is the one that cost me: 0.2 on the Criteria side, 0.3 in the CTE, two definitions of the same retriever drifting apart in silence.

RRF needs three ranked candidate sets, a ROW_NUMBER() window function over each, and a FULL OUTER JOIN to keep a book that only one retriever found. The Criteria API can express none of those, so the fused query is a single @Query(nativeQuery = true), and I left the seam visible:

sql|BookRepository.java, the fusion CTEs (abridged)
WITH lexical AS (
    SELECT b.id,
           ROW_NUMBER() OVER (ORDER BY ts_rank_cd(
               b.search_vector, websearch_to_tsquery('english', :q)) DESC,
               b.id) AS rank
    FROM book b
    WHERE b.search_vector @@ websearch_to_tsquery('english', :q)
    ORDER BY rank LIMIT :limitPerRetriever
),
fuzzy AS ( ... ROW_NUMBER() OVER (ORDER BY GREATEST(
    similarity(b.title, :q), similarity(a.name, :q)) DESC, b.id) ... ),
synonym AS ( ... ROW_NUMBER() OVER (ORDER BY ts_rank( ... )) ... ),
fused AS (
    SELECT COALESCE(l.id, f.id, s.id) AS book_id,
           COALESCE(1.0 / (:k + l.rank), 0)
         + COALESCE(1.0 / (:k + f.rank), 0)
         + COALESCE(1.0 / (:k + s.rank), 0) AS fused_score
    FROM lexical l
    FULL OUTER JOIN fuzzy f ON f.id = l.id
    FULL OUTER JOIN synonym s ON s.id = COALESCE(l.id, f.id)
)

COALESCE(l.id, f.id) in that second join is load-bearing. After the first join an id lives in l.id or in f.id but not reliably in either, so joining synonym on l.id alone would drop every fuzzy-only match. The obvious-looking s.id = l.id OR s.id = f.id isn't equivalent. Postgres won't even run it: a FULL JOIN needs a hashable or mergeable condition.

Here's what the arithmetic buys, on q=great house:

json|GET /api/search?q=great house&mode=FUSED
{
  "title": "The House of the Seven Gables",
  "score": 0.0476,
  "rankContributions": [
    { "retriever": "LEXICAL", "rank": 2, "contribution": 0.01613 },
    { "retriever": "FUZZY",   "rank": 5, "contribution": 0.01538 },
    { "retriever": "SYNONYM", "rank": 2, "contribution": 0.01613 }
  ]
},
{
  "title": "Far from the Madding Crowd",
  "score": 0.0328,
  "rankContributions": [
    { "retriever": "LEXICAL", "rank": 1, "contribution": 0.01639 },
    { "retriever": "FUZZY",   "rank": null, "contribution": null },
    { "retriever": "SYNONYM", "rank": 1, "contribution": 0.01639 }
  ]
}

The House of the Seven Gables wins while being top of nothing. It's second, fifth and second. Far from the Madding Crowd is first in two retrievers and still loses, because three moderate agreements out-total two confident ones. That damping is the mechanism, not a side effect: rank 1 contributes 1/61 and rank 5 contributes 1/65, so no single retriever's favourite can run away with the result.

The bars are drawn from rank position, not from the contribution term, which barely varies once k is 60. A fused score you cannot take apart is a number you have to trust on faith.

What two paths cost me

Now the bill. Having a Criteria path and a native path means having two definitions of the same retriever, and they drifted. The Criteria fuzzy predicate used similarity() >= 0.2. The native fuzzy CTE used %, whose default threshold is 0.3. So Stevensen returned three books through fuzzy mode and zero through fused mode, and nothing failed anywhere. No error, no warning, no red test.

The fix was to stop having two thresholds:

yaml|application.yaml
spring:
  datasource:
    hikari:
      connection-init-sql: SET pg_trgm.similarity_threshold = 0.2

Hikari doesn't reset session state between borrows, so that GUC is set once per physical connection and both paths inherit it. Registering % as a pattern function was the other half, so the Criteria predicate uses the same operator as the CTE instead of a comparison that only looks equivalent.

The functional synonym index is fragile in two directions

idx_book_synonym_vector indexes to_tsvector('book_synonym_search', title || ' ' || excerpt), and a functional index is only usable when the query's expression matches it exactly. Change how that expression is built in Java and you silently lose the index without breaking a single test.

The second direction is worse, because it changes answers rather than speed. A database session reads a .syn file once, the first time it uses that dictionary in the session. Edit the file on a live database and any session that already touched the dictionary keeps the old contents while a fresh session sees the new one. Two connections to the same database, disagreeing. The manual's remedy for the sessions4 is a dummy ALTER TEXT SEARCH DICTIONARY book_synonym ( dummy ), an option removal that's allowed to remove nothing, and the index still needs its own REINDEX on top, because its stored lexemes came from the old mapping. Until both have happened, an index scan and a sequential scan of the same table can return different rows for the same query, with no error anywhere. For a demo I take the blunt path and rebuild the container.

What this gives you, and where it stops

Three retrievers compose. Fusing them does not, and knowing exactly where that flips is worth more than any abstraction that hides it. Everything up to the fusion is ordinary Spring Data: Specification chains, one CriteriaQuery, two registered operators. The moment you need CTEs and window functions you are writing SQL, and the honest move is to write it in one place and leave it visible.

The rest of it is less work than its reputation suggests. A stored tsvector, a six-pair synonym file and one pg_trgm extension gave me typo tolerance, phrase queries, negation, highlighting and a vocabulary bridge over one table, with nothing to keep in sync and nothing extra to run.

Which makes the boundaries worth naming, because this is a retrieval layer and not a search product:

  • This is not BM25. ts_rank and ts_rank_cd score a row from that row's tsvector alone. Postgres full-text search keeps no corpus-wide statistics, so there is no inverse-document-frequency term at all: a word appearing in 2 of 77 books weighs exactly the same as one in 70.
  • There is no reranker in the database. RRF is rank arithmetic. It fuses positions and knows nothing about relevance beyond them. A cross-encoder or an LLM reranker is a stage after this one ends, and so are pgvector and embeddings.
  • The demo endpoint has no auth and no rate limit, and a ranking query is not a cheap thing to leave open.
  • One PG 18 upgrade note I did not reproduce. The release notes change full-text search to read configuration files and dictionaries through the cluster's default collation provider rather than always libc, and recommend that clusters on a non-libc provider upgrading via pg_upgrade "reindex all indexes related to full-text search and pg_trgm after the upgrade".1 This repo runs the image default, so I never saw it. I'm passing on the release note, not a result.

The whole thing is at github.com/lukas-grigis/spring-postgres-fts. mise run demo starts Postgres, builds the app and serves the UI; 36 Testcontainers tests run against a real Postgres 18 with no mocked database anywhere. The README maps the load-bearing findings to the test that proves each one. The collation upgrade note and the synonym-index fragility section are sourced to the release notes and the manual, and say so.

Next up is the leg I cut on purpose: pgvector and semantic search, which answers a different question than any of these three. If something here breaks on your machine, open an issue.

Footnotes

  1. PostgreSQL 18 release notes, postgresql.org/docs/release/18.0. Both quotes in this post come from there: "Allow generated columns to be virtual, and make them the default [...] The write behavior can still be specified via the STORED option" (Peter Eisentraut, Jian He, Richard Guo, Dean Rasheed), and the full-text-search collation item (Peter Eisentraut), which notes that clusters defaulting to a non-libc provider "could observe changes in behavior of some full-text search functions, as well as the pg_trgm extension". 2

  2. Verified with javap against the real spring-data-jpa-4.1.0.jar. The transcript, with static and default members elided, is in the repo at docs/POSTGRES-FTS.md.

  3. Gordon V. Cormack, Charles L. A. Clarke and Stefan Buettcher, "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods", SIGIR 2009, cormack.uwaterloo.ca/cormacksigir09-rrf.pdf. The paper fixes k = 60 "during a pilot investigation and not altered during subsequent validation", finds it near-optimal on its test collections, and adds that "the choice was not critical".

  4. PostgreSQL 18 manual, Text search dictionaries. The synonym template, the source target file format, the per-session read of that file, and the dummy ALTER TEXT SEARCH DICTIONARY that forces a reload are all documented there. The index-time half of the substitution is not spelled out.

Frequently asked questions

Can I call Postgres full-text search functions from the JPA Criteria API?

Yes, and most of it needs no setup. Hibernate renders an unregistered cb.function call straight through as ts_rank(args) and lets Postgres resolve it, so ts_rank, ts_rank_cd, ts_headline, similarity, to_tsvector and websearch_to_tsquery all work with zero registration. Only @@ and % need a FunctionContributor with registerPattern, because they are operators and have no function-call form to fall through to.

How do I make Postgres full-text search tolerate typos?

Use the pg_trgm extension, not the full-text stemmer. Stemming cannot connect Stevensen to Stevenson because they reduce to different lexemes, but trigram similarity compares three-character runs and scores the pair at 0.27. Match with the % operator so a gin_trgm_ops index can answer it, and rank with similarity(). % uses pg_trgm.similarity_threshold, which defaults to 0.3.

How do I add synonyms to Postgres full-text search?

Create a TEXT SEARCH DICTIONARY with TEMPLATE = synonym over a source target file in the server's $SHAREDIR/tsearch_data, then copy the english configuration and map its token types to your dictionary first. List each pair in one direction only. The substitution also runs at index time, so a pair written both ways swaps both sides and matches nothing.

Do I need pgvector for good search in Postgres?

Not for typo tolerance, synonyms or exact terms. Those are lexical problems, and tsvector, pg_trgm and a synonym dictionary solve them with no embedding model, no API key and no second daemon. pgvector answers a different question, semantic similarity, and it comes after this one.

Resources

Share this article

Enjoyed this article?

Stay in the Loop

Get notified when I publish new articles. No spam, unsubscribe anytime.

More Posts