DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • How to Store Text in PostgreSQL: Tips, Tricks, and Traps
  • Implementing Sharding in PostgreSQL: A Comprehensive Guide
  • Integrating Lakeflow Connect With PostgreSQL: A Developer’s Complete Hands-On Guide From the Field
  • Modify JSON Data in Postgres and Hibernate 6

Trending

  • Implementing Secure API Gateways for Microservices Architecture
  • Build a GitHub Slack Bot With AWS Bedrock and MCP, Part 1
  • The Middleware Gap in AI Agent Frameworks
  • Frame Buffer Hashing for Visual Regression on Embedded Devices
  1. DZone
  2. Data Engineering
  3. Databases
  4. Ranking Full-Text Search Results in PostgreSQL Using ts_rank and ts_rank_cd With Hibernate 6 and posjsonhelper

Ranking Full-Text Search Results in PostgreSQL Using ts_rank and ts_rank_cd With Hibernate 6 and posjsonhelper

Learn how to rank PostgreSQL full-text search results by relevance using ts_rank, ts_rank_cd, and Hibernate’s Criteria API for better search UX.

By 
Szymon Tarnowski user avatar
Szymon Tarnowski
DZone Core CORE ·
Oct. 23, 25 · Tutorial
Likes (3)
Comment
Save
Tweet
Share
3.2K Views

Join the DZone community and get the full member experience.

Join For Free

In a previous article, we explored how to implement full-text search in PostgreSQL using Hibernate 6 and the posjsonhelper library. We built queries with to_tsvector, to_tsquery, and their simpler wrappers for the plainto_tsquery, phraseto_tsquery, and websearch_to_tsquery functions.

This time, we’ll extend that foundation and explore how to rank search results based on their relevance using PostgreSQL’s built-in ranking functions like ts_rank and ts_rank_cd.

We’ll also demonstrate how to use them programmatically in Hibernate through the posjsonhelper library.

Why Ranking Matters

A typical full-text search returns all matching records, but not necessarily in a meaningful order.
For example, imagine searching “Postgres ranking” in a database of articles. Some records might mention the term once, while others include it repeatedly or in their titles — yet both would appear equally if we rely only on the @@ operator.

That’s where ranking functions come in.

  • ts_rank — computes a relevance score based on term frequency and inverse document frequency (TF/IDF).
  • ts_rank_cd — a variant using cover density ranking, favoring documents where search terms appear close together.

(For more information about built-in ranking methods, check Postgres documentation.)

Ranking lets your application:

  • Prioritize results by relevance.
  • Improve search UX by showing the best matches first.
  • Keep using PostgreSQL’s native features — no need for external search engines.

When Full-Text Search Alone Is Enough

While modern projects often explore vector similarity search (e.g., using pgvector with embeddings), not every system needs that level of complexity.

Full-text search — especially when enhanced with ranking — is usually sufficient when:

  • You want exact or linguistic matches, not semantic ones.
  • Your dataset is moderate in size (hundreds of thousands, not billions of rows).
  • You want explainable ranking logic (based on term frequency and proximity).
  • You need to stay entirely within PostgreSQL — no additional infrastructure.

If your users expect “semantic” search, then vector embeddings are worth considering. But for structured text like product descriptions, articles, or messages, Postgres full-text search with ranking is often all you need.

PostgreSQL Ranking Functions Overview

Both ts_rank and ts_rank_cd take a tsvector (your indexed document) and a tsquery (your search query):

SQL
 
SELECT ts_rank(to_tsvector('english', content), to_tsquery('english', 'postgres & ranking'));


They return a numeric score representing how relevant the document is to the search query.
You can then order results using this score:

SQL
 
ORDER BY ts_rank(to_tsvector('english', content), to_tsquery('english', 'postgres & ranking')) DESC;


Implementing Ranking in Hibernate Using posjsonhelper

The posjsonhelper library adds type-safe, Criteria-API-compatible support for PostgreSQL functions like to_tsvector, to_tsquery, and text operators. 

Although it doesn’t include wrappers at this moment for ranking functions, you can easily invoke them through Hibernate’s CriteriaBuilder#function method.

Let’s see how this works in practice.

Example 1: Ranking With ts_rank

Java
 
public List<Item> findItemsByWebSearchToTSQuerySortedByTsRank(String phrase, boolean ascSort) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Item> cq = cb.createQuery(Item.class);
        Root<Item> root = cq.from(Item.class);

        // Build weighted tsvector using posjsonhelper functions
        Expression<String> shortNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("A")
        );

        Expression<String> fullNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("B")
        );

        Expression<String> shortDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("C")
        );

        Expression<String> fullDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("D")
        );

        // Concatenate tsvectors (|| operator)
        SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

        // Build tsquery
        Expression<String> queryExpr = new WebsearchToTSQueryFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, phrase);

        // WHERE clause using @@ operator
        TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

        cq.where(matches);

        // Ranking
        Expression<Double> rankExpr = cb.function(
                "ts_rank", Double.class,
                fullVector,
                queryExpr
        );

        cq.orderBy(ascSort ? cb.asc(rankExpr) : cb.desc(rankExpr));

        return entityManager.createQuery(cq).getResultList();
    }


The full code example can be found here.

This query produces SQL similar to: 

SQL
 
select
        i1_0.id,
        i1_0.full_description,
        i1_0.full_name,
        i1_0.short_description,
        i1_0.short_name 
    from
        item i1_0 
    where
        (
            (
                setweight(to_tsvector(?::regconfig, i1_0.short_name), 'A')||setweight(
                    to_tsvector(?::regconfig, i1_0.full_name), 'B'
                )
            )||(
                setweight(to_tsvector(?::regconfig, i1_0.short_description), 'C')||setweight(
                    to_tsvector(?::regconfig, i1_0.full_description), 'D'
                )
            )
        ) @@ websearch_to_tsquery(?::regconfig, ?) 
    order by
        ts_rank(((setweight(to_tsvector(?::regconfig, i1_0.short_name), 'A')||setweight(to_tsvector(?::regconfig, i1_0.full_name), 'B'))||(setweight(to_tsvector(?::regconfig, i1_0.short_description), 'C')||setweight(to_tsvector(?::regconfig, i1_0.full_description), 'D'))), websearch_to_tsquery('english', ?))


The same thing can be implemented with HQL language, like below:

Java
 
public List<Item> findItemsByWebSearchToTSQuerySortedByTsRankInHQL(String phrase, boolean ascSort) {
        String statement = "from Item as item where " +
                "text_operator_function(" + // text_operator_function - start
                "concat(" + // main concat - start
                "concat(" + // first concat - start
                "function('setweight', to_tsvector('%1$s', item.shortName), 'A')" +
                "," +
                "function('setweight', to_tsvector('%1$s', item.fullName), 'B')" +
                ")" + // first concat - end
                "," + // main concat - separator
                "concat(" + // second concat - start
                "function('setweight', to_tsvector('%1$s', item.shortDescription), 'C')" +
                "," +
                "function('setweight', to_tsvector('%1$s', item.fullDescription), 'D')" +
                ")" + // first second - end
                ")" + // main concat - end
                "," + // text_operator_function - separator

                "websearch_to_tsquery(cast_operator_function('%1$s','regconfig'), :phrase)" + // websearch_to_tsquery operator

                ")" + // text_operator_function - end
                " order by " + // order - start


                "function('ts_rank', " + // ts_rank function - start
                "concat(" + // main concat - start
                "concat(" + // first concat - start
                "function('setweight', to_tsvector('%1$s', item.shortName), 'A')" +
                "," +
                "function('setweight', to_tsvector('%1$s', item.fullName), 'B')" +
                ")" + // first concat - end
                "," + // main concat - separator
                "concat(" + // second concat - start
                "function('setweight', to_tsvector('%1$s', item.shortDescription), 'C')" +
                "," +
                "function('setweight', to_tsvector('%1$s', item.fullDescription), 'D')" +
                ")" + // first second - end
                ")" + // main concat - end
                "," + // ts_rank function - separator

                "websearch_to_tsquery(cast_operator_function('%1$s','regconfig'), :phrase)" + // websearch_to_tsquery operator

                ")" + // ts_rank function - end
                (ascSort ? " asc" : "desc");

        TypedQuery<Item> query = entityManager.createQuery(statement.formatted(ENGLISH_CONFIGURATION), Item.class);
        query.setParameter("phrase", phrase);
        return query.getResultList();
    }


The full code example can be found here. 

Example 2: Ranking With ts_rankd_cd

Java
 
public List<Item> findItemsByWebSearchToTSQuerySortedByTsRankCd(String phrase, boolean ascSort) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Item> cq = cb.createQuery(Item.class);
        Root<Item> root = cq.from(Item.class);

        // Build weighted tsvector using posjsonhelper functions
        Expression<String> shortNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("A")
        );

        Expression<String> fullNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("B")
        );

        Expression<String> shortDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("C")
        );

        Expression<String> fullDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("D")
        );

        // Concatenate tsvectors (|| operator)
        SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

        // Build tsquery
        Expression<String> queryExpr = new WebsearchToTSQueryFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, phrase);

        // WHERE clause using @@ operator
        TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

        cq.where(matches);

        // Ranking
        Expression<Double> rankExpr = cb.function(
                "ts_rank_cd", Double.class,
                fullVector,
                queryExpr
        );

        cq.orderBy(ascSort ? cb.asc(rankExpr) : cb.desc(rankExpr));

        return entityManager.createQuery(cq).getResultList();
    }


Code looks almost identical; the only difference is the use of the ts_rank_cd function.

The full code example can be found here.

Example 3: Custom Weights and Normalization

ts_rank and ts_rank_cd support additional arguments — for example, passing a custom weight array to control how much each part of your text contributes to the overall rank, just like in the example below:

Java
 
public List<Item> findItemsByWebSearchToTSQuerySortedByTsRankWithCustomWeight(String phrase, boolean ascSort, double[] weights) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Item> cq = cb.createQuery(Item.class);
        Root<Item> root = cq.from(Item.class);

        // Build weighted tsvector using posjsonhelper functions
        Expression<String> shortNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("A")
        );

        Expression<String> fullNameVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullName"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("B")
        );

        Expression<String> shortDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("shortDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("C")
        );

        Expression<String> fullDescriptionVec = cb.function("setweight", String.class,
                new TSVectorFunction(root.get("fullDescription"), new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), (NodeBuilder) cb),
                cb.literal("D")
        );

        // Concatenate tsvectors (|| operator)
        SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

        // Build tsquery
        Expression<String> queryExpr = new WebsearchToTSQueryFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, phrase);

        // WHERE clause using @@ operator
        TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

        cq.where(matches);

        // Ranking
        Expression<Double> rankExpr = cb.function(
                "ts_rank", Double.class,
                new ArrayFunction<>((NodeBuilder) cb, Arrays.stream(weights).mapToObj(w -> (SqmExpression<Double>) cb.literal(w)).toList(), hibernateContext)
                , fullVector
                , queryExpr
        );

        cq.orderBy(ascSort ? cb.asc(rankExpr) : cb.desc(rankExpr));

        return entityManager.createQuery(cq).getResultList();
    }


Full code example can be found here; the same example, but implemented with HQL, can be found here.

Performance Considerations

While ranking functions such as ts_rank and ts_rank_cd significantly improve the quality of search results, they also introduce a computational and I/O overhead that’s important to understand, especially when operating on large datasets.

When PostgreSQL executes a full-text search with ranking, it typically performs two main operations:

  1. Index-based filtering — The @@ operator uses a GIN or GiST index to quickly locate the documents that match the query terms.
    This part is very efficient and happens almost entirely in memory.
  2. Ranking and sorting — Once matching rows are found, PostgreSQL must read the corresponding tsvector values from disk (or cache) and compute the relevance score for each row. This step involves I/O operations and CPU processing, since PostgreSQL needs to access the document lexemes and calculate how well they match the query. If the matching dataset is large, this can become a noticeable bottleneck — even when proper indexes are in place.

In other words, the index helps PostgreSQL find relevant rows quickly, but ranking requires touching each matched row’s data, which may trigger additional reads from storage. The cost grows roughly with the number of results that pass the @@ filter.

For example:

  • Ranking 100 results is typically negligible (a few milliseconds).
  • Ranking 10,000 or more results may involve enough I/O to impact response times, especially on spinning disks or when data doesn’t fit in shared buffers.
  • Sorting ranked results (e.g., ORDER BY ts_rank(...) DESC) adds additional work, since PostgreSQL must maintain an in-memory or temporary sort buffer.

To mitigate these effects:

  • Always filter first, and rank only the subset of matched rows.
  • Consider using precomputed tsvector columns and GIN indexes to minimize recomputation.
  • For very high-volume search workloads, caching top results or using a dedicated search engine (like Elasticsearch) might be beneficial.

Ranking is a powerful feature, but it’s not free. When applied to a large dataset, even with correct indexing, the I/O required to fetch and score every matched row can impact overall query performance. Designing queries and indexes with this in mind will ensure that your search remains both relevant and responsive.

Conclusion

By extending our earlier full-text search implementation with ts_rank and ts_rank_cd, we can now sort results by relevance, not just existence.

This approach combines the power of PostgreSQL’s ranking functions with the flexibility of Hibernate’s Criteria API, keeping your queries both expressive and type-safe.

While more advanced vector search solutions exist, for most business applications, PostgreSQL’s full-text search with ranking remains a simple, explainable, and highly effective solution.

Hibernate sql PostgreSQL

Opinions expressed by DZone contributors are their own.

Related

  • How to Store Text in PostgreSQL: Tips, Tricks, and Traps
  • Implementing Sharding in PostgreSQL: A Comprehensive Guide
  • Integrating Lakeflow Connect With PostgreSQL: A Developer’s Complete Hands-On Guide From the Field
  • Modify JSON Data in Postgres and Hibernate 6

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook