PostgreSQL全文搜索结果排名指南:使用ts_rank和ts_rank_cd

本文详细介绍了如何在PostgreSQL中使用ts_rank和ts_rank_cd函数对全文搜索结果进行相关性排名,并结合Hibernate 6和posjsonhelper库实现程序化应用,提升搜索用户体验。

在PostgreSQL中排名全文搜索结果

在之前的文章中,我们探讨了如何使用Hibernate 6和posjsonhelper库在PostgreSQL中实现全文搜索。我们使用to_tsvector、to_tsquery及其更简单的包装器构建了查询。这次,我们将扩展这一基础,探索如何使用PostgreSQL的内置排名函数(如ts_rank和ts_rank_cd)基于相关性对搜索结果进行排名。我们还将演示如何通过posjsonhelper库在Hibernate中以编程方式使用它们。

为什么排名很重要

典型的全文搜索会返回所有匹配的记录,但不一定是有意义的顺序。例如,假设在文章数据库中搜索"Postgres ranking"。有些记录可能提到该术语一次,而其他记录可能在其标题中重复出现或包含它——然而,如果我们仅依赖@@运算符,两者将同样出现。

这就是排名函数的作用:

  • ts_rank — 基于词频和逆文档频率(TF/IDF)计算相关性得分
  • ts_rank_cd — 使用覆盖密度排名的变体,偏爱搜索术语紧密出现的文档

排名让您的应用程序能够:

  • 按相关性对结果进行优先级排序
  • 通过首先显示最佳匹配来改善搜索用户体验
  • 继续使用PostgreSQL的本机功能——无需外部搜索引擎

何时仅需全文搜索

虽然现代项目经常探索向量相似性搜索(例如,使用带嵌入的pgvector),但并非每个系统都需要那种复杂性。全文搜索——特别是当增强排名时——通常在以下情况下足够:

  • 您想要精确或语言匹配,而不是语义匹配
  • 您的数据集大小适中(数十万,不是数十亿行)
  • 您想要可解释的排名逻辑(基于词频和接近度)
  • 您需要完全停留在PostgreSQL内——无需额外基础设施

如果您的用户期望"语义"搜索,那么向量嵌入值得考虑。但对于结构化文本(如产品描述、文章或消息),带有排名的Postgres全文搜索通常就是您所需要的全部。

PostgreSQL排名函数概述

ts_rank和ts_rank_cd都接受一个tsvector(您的索引文档)和一个tsquery(您的搜索查询):

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

它们返回一个数字分数,表示文档与搜索查询的相关程度。然后您可以使用此分数对结果进行排序:

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

使用posjsonhelper在Hibernate中实现排名

posjsonhelper库添加了对PostgreSQL函数(如to_tsvector、to_tsquery和文本运算符)的类型安全、Criteria-API兼容支持。尽管此刻它不包括排名函数的包装器,但您可以通过Hibernate的CriteriaBuilder#function方法轻松调用它们。

让我们看看这在实践中如何工作。

示例1:使用ts_rank进行排名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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);

    // 使用posjsonhelper函数构建加权tsvector
    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")
    );

    // 连接tsvectors(||运算符)
    SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

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

    // 使用@@运算符的WHERE子句
    TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

    cq.where(matches);

    // 排名
    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();
}

完整代码示例可以在这里找到。

此查询产生类似于以下的SQL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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', ?))

同样的事情可以用HQL语言实现,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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();
}

完整代码示例可以在这里找到。

示例2:使用ts_rank_cd进行排名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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);

    // 使用posjsonhelper函数构建加权tsvector
    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")
    );

    // 连接tsvectors(||运算符)
    SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

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

    // 使用@@运算符的WHERE子句
    TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

    cq.where(matches);

    // 排名
    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();
}

代码看起来几乎相同;唯一的区别是使用了ts_rank_cd函数。

完整代码示例可以在这里找到。

示例3:自定义权重和归一化

ts_rank和ts_rank_cd支持附加参数——例如,传递自定义权重数组以控制文本的每个部分对整体排名的贡献程度,就像下面的示例一样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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);

    // 使用posjsonhelper函数构建加权tsvector
    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")
    );

    // 连接tsvectors(||运算符)
    SqmExpression<String> fullVector = (SqmExpression<String>) cb.concat(cb.concat(shortNameVec, fullNameVec), cb.concat(shortDescriptionVec, fullDescriptionVec));

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

    // 使用@@运算符的WHERE子句
    TextOperatorFunction matches = new TextOperatorFunction((NodeBuilder) cb, fullVector, new WebsearchToTSQueryFunction((NodeBuilder) cb, new RegconfigTypeCastOperatorFunction((NodeBuilder) cb, ENGLISH_CONFIGURATION, hibernateContext), phrase), hibernateContext);

    cq.where(matches);

    // 排名
    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();
}

完整代码示例可以在这里找到;使用HQL实现的相同示例可以在这里找到。

性能考虑

虽然ts_rank和ts_rank_cd等排名函数显著提高了搜索结果的质量,但它们也引入了计算和I/O开销,这一点很重要理解,尤其是在处理大型数据集时。

当PostgreSQL执行带有排名的全文搜索时,它通常执行两个主要操作:

  1. 基于索引的过滤 — @@运算符使用GIN或GiST索引快速定位匹配查询术语的文档。这部分非常高效,几乎完全在内存中发生。

  2. 排名和排序 — 一旦找到匹配的行,PostgreSQL必须从磁盘(或缓存)读取相应的tsvector值,并计算每行的相关性分数。此步骤涉及I/O操作和CPU处理,因为PostgreSQL需要访问文档词位并计算它们与查询的匹配程度。如果匹配的数据集很大,这可能成为一个明显的瓶颈——即使存在正确的索引。

换句话说,索引帮助PostgreSQL快速找到相关行,但排名需要触及每个匹配行的数据,这可能会触发来自存储的额外读取。成本大致随着通过@@筛选器的结果数量增长。

例如:

  • 排名100个结果通常可以忽略不计(几毫秒)
  • 排名10,000个或更多结果可能涉及足够的I/O以影响响应时间,尤其是在旋转磁盘上或当数据不适合共享缓冲区时
  • 对排名结果进行排序(例如,ORDER BY ts_rank(…) DESC)增加了额外工作,因为PostgreSQL必须维护内存中或临时排序缓冲区

为了减轻这些影响:

  • 始终先过滤,然后仅对匹配行的子集进行排名
  • 考虑使用预计算的tsvector列和GIN索引以最小化重新计算
  • 对于非常高容量的搜索工作负载,缓存顶部结果或使用专用搜索引擎(如Elasticsearch)可能有益

排名是一个强大的功能,但它不是免费的。当应用于大型数据集时,即使有正确的索引,获取和评分每个匹配行所需的I/O也会影响整体查询性能。在设计查询和索引时考虑到这一点将确保您的搜索既相关又响应迅速。

结论

通过使用ts_rank和ts_rank_cd扩展我们之前的全文搜索实现,我们现在可以按相关性对结果进行排序,而不仅仅是存在性。这种方法将PostgreSQL排名函数的功能与Hibernate Criteria API的灵活性相结合,使您的查询既富有表现力又类型安全。

虽然存在更高级的向量搜索解决方案,但对于大多数业务应用程序,带有排名的PostgreSQL全文搜索仍然是一个简单、可解释且高度有效的解决方案。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计