0.0973585?探究ts_rank的score为什么这么低

164 阅读6分钟

最近在使用postgres的ts_rank进行排序找到最符合关键词要求得内容时发现: 即使是相似的内容,得分也是非常非常得低(其中一个case是0.0973585)。看起来很奇怪,非常不可信。于是我又做了一个简单测的测试:

SELECT ts_rank(to_tsvector('english', 'skirt'), to_tsquery('skirt'));

讲道理,这已经是完全匹配得内容了,预期得分应该非常高(如果按匹配度来看应该有1了),但实际得rank分数却是非常得低:

这就引出了一个问题:为什么分数这么低?这么低的分数是不是对的?ts_rank时得计算逻辑是怎样的?带着这个疑问,我们深入探究一下。

postgres全文搜索

ts_vector

ts_vector是postgres中用于全文检索的主要结构,在这个结构中,会将文本信息转换为词位 + 位置信息的格式。

SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 结果: 'brown' : 3  'dog' : 9  'fox' : 4  'jump' : 5  'lazi' : 8  'quick' : 2

ts_query

表示搜索的查询条件

SELECT to_tsquery('english', 'jumping & quick');
-- 结果: 'jump' & 'quick' 

全文搜索流程

基于上面的示例,我们来看一下这里全文搜索的流程。整个处理过程如上图所示。

预处理阶段

预处理阶段首先会对文本内容进行分词,得到一个个token,然后会对这里的分词结果进行一系列标准化操作,比如转小写、移除一些停用词、词干提取等。再之后,就会生成包含词位和位置信息的ts_vector。之后,为了加速检索,还可以为这部分内容建立gin索引。

查询阶段

查询时会先对查询内容进行解析,然后生成对应的ts_query结构,最后执行索引匹配,获取相关相关性评分,最终得到一个最终的评分结果。前文提到的ts_rank发生在相关性评分处。

ts_rank

calc_rank是ts_rank的核心方法,它根据输入的权重 (weights)、文本向量 (TSVector)、查询 (TSQuery) 和指定的归一化方法 (method) 来计算相关性评分。顺着源码来看下:

calc_rank(const float *w, TSVector t, TSQuery q, int32 method)
{
        QueryItem  *item = GETQUERY(q);
        float                res = 0.0;
        int                        len;

        if (!t->size || !q->size)
                return 0.0;

        /* XXX: What about NOT? */
        res = (item->type == QI_OPR && (item->qoperator.oper == OP_AND ||
                                                                        item->qoperator.oper == OP_PHRASE)) ?
                calc_rank_and(w, t, q) :
                calc_rank_or(w, t, q);

        if (res < 0)
                res = 1e-20f;

        if ((method & RANK_NORM_LOGLENGTH) && t->size > 0)
                res /= log((double) (cnt_length(t) + 1)) / log(2.0);

        if (method & RANK_NORM_LENGTH)
        {
                len = cnt_length(t);
                if (len > 0)
                        res /= (float) len;
        }

        /* RANK_NORM_EXTDIST not applicable */

        if ((method & RANK_NORM_UNIQ) && t->size > 0)
                res /= (float) (t->size);

        if ((method & RANK_NORM_LOGUNIQ) && t->size > 0)
                res /= log((double) (t->size + 1)) / log(2.0);

        if (method & RANK_NORM_RDIVRPLUS1)
                res /= (res + 1);

        return res;
}

根据查询类型(OP_ANDOP_PHRASE),选择 calc_rank_andcalc_rank_or 函数计算基本相关性。calc_rank_and的执行逻辑如下:

static float
calc_rank_and(const float *w, TSVector t, TSQuery q)
{
        WordEntryPosVector **pos;
        WordEntryPosVector1 posnull;
        WordEntryPosVector *POSNULL;
        int                        i,
                                k,
                                l,
                                p;
        WordEntry  *entry,
                           *firstentry;
        WordEntryPos *post,
                           *ct;
        int32                dimt,
                                lenct,
                                dist,
                                nitem;
        float                res = -1.0;
        QueryOperand **item;
        int                        size = q->size;

        item = SortAndUniqItems(q, &size);
        if (size < 2)
        {
                pfree(item);
                return calc_rank_or(w, t, q);
        }
        pos = (WordEntryPosVector **) palloc0(sizeof(WordEntryPosVector *) * q->size);

        /* A dummy WordEntryPos array to use when haspos is false */
        posnull.npos = 1;
        posnull.pos[0] = 0;
        WEP_SETPOS(posnull.pos[0], MAXENTRYPOS - 1);
        POSNULL = (WordEntryPosVector *) &posnull;

        for (i = 0; i < size; i++)
        {
                firstentry = entry = find_wordentry(t, q, item[i], &nitem);
                if (!entry)
                        continue;

                while (entry - firstentry < nitem)
                {
                        if (entry->haspos)
                                pos[i] = _POSVECPTR(t, entry);
                        else
                                pos[i] = POSNULL;

                        dimt = pos[i]->npos;
                        post = pos[i]->pos;
                        for (k = 0; k < i; k++)
                        {
                                if (!pos[k])
                                        continue;
                                lenct = pos[k]->npos;
                                ct = pos[k]->pos;
                                for (l = 0; l < dimt; l++)
                                {
                                        for (p = 0; p < lenct; p++)
                                        {
                                                dist = abs((int) WEP_GETPOS(post[l]) - (int) WEP_GETPOS(ct[p]));
                                                if (dist || (dist == 0 && (pos[i] == POSNULL || pos[k] == POSNULL)))
                                                {
                                                        float                curw;

                                                        if (!dist)
                                                                dist = MAXENTRYPOS;
                                                        curw = sqrt(wpos(post[l]) * wpos(ct[p]) * word_distance(dist));
                                                        res = (res < 0) ? curw : 1.0 - (1.0 - res) * (1.0 - curw);
                                                }
                                        }
                                }
                        }

                        entry++;
                }
        }
        pfree(pos);
        pfree(item);
        return res;
}

之后,再根据method方法来对结果进行归一化,从而得到最终分数。

  • RANK_NORM_LOGLENGTH:对向量长度取对数。
  • RANK_NORM_LENGTH:按向量长度归一化。
  • RANK_NORM_UNIQ:按唯一词数量归一化。
  • RANK_NORM_RDIVRPLUS1:按 res / (res + 1) 归一化。

其他

AI内容总结

最开始的时候我尝试直接使用AI来获取执行逻辑,但后面发现感觉和现在的版本有点对不上(比如里面提到的RankNormDef结构,在源文件中没有找到),就又去找了源码看了下。但我觉得思路上可以作为参考,所以在这里做了保留。以下内容是根据AI生成的内容进行梳理之后的结果,感兴趣的可以看下。

ts_rank的方法签名如下:

ts_rank(weights, tsvector, tsquery, normalization)

接下来我们看看ts_rank具体如何计算,利用了哪些计算信息。

score = Σ( weight * log(tf + 1) * position_weight ) / length_norm

主要通过词频、位置权重、字段权重和标准化来进行计算。

词频

词频的计算采用字段权重的对数缩放来进行计算。对数缩放避免高频词过度主导。


for (每个查询词项) {
    if (词项在文档中存在) {
        /* 词频贡献 = 权重 * log(词频 + 1) */
        freq += w[pos] * log( (float)item->npos + 1 );
    }
}

位置权重

位置权重表现了位置对评分的影响。计算伪代码如下:

for (每个匹配词项的位置) {
    pos_weight = 1.0 / (位置距离因子);
    total += pos_weight;
 }

从计算公式来看,我们可以看出相邻词匹配(jump quick)比分散匹配(jump .... quick)得分高,位置靠前的词比位置靠后的词得分要高。

字段权重

字段权重的数据结构定义如下:

typedef struct {
    float4  weights[4];    /* A,B,C,D 权重 */
    float4  length_norm;   /* 长度标准化因子 */
} RankNormDef;

这里权重等级分为4个等级(A,B,C,D)。默认不同级别的权重配置为:{0.1, 0.2, 0.4, 1.0},可以通过setweight方法来调整查询中不同字段的权重,例如:

SELECT ts_rank(
  setweight(to_tsvector('english', title), 'A') || 
  setweight(to_tsvector('english', body), 'D'),
  to_tsquery('query')
);

在前面计算词频的过程中会使用到。

长度标准化

RankNormDef中的length_norm主要用于控制文本长度标准化,

length_norm = (文档词位数 ^ norm_factor)

  其norm_factor计算主要根据normalization参数决定:

normalization 值对应宏定义length_norm 计算公式适用场景
0(无)1.0 (禁用标准化)需要绝对分数的场景
1RANK_NORM_LOGLENGTH1 + log(文档词位数)默认选项,通用场景
2RANK_NORM_LENGTH文档词位数强长度惩罚
4RANK_NORM_LOGDOCLEN1 + log(1 + 文档词位数/平均长度)已弃用
8RANK_NORM_DOCLEN(文档词位数/平均长度)已弃用
16RANK_NORM_EXTDIST额外考虑词项距离短语搜索优化
116 (17)组合选项(1 + log(长度)) * 距离因子精确短语匹配

假设有两个文档,一个文档的词位数是5,一个文档的词位数是20,在长度标准化之前两个文档计算的最终分数都是0.8,使用默认标准化。那么他们的最终得分如下:

 # 文档1 (5词)
length_norm = log(1 + 5) ≈ 1.79
final_score = 0.8 / 1.790.45
# 文档2 (20词)
length_norm = log(1 + 20) ≈ 3.04
final_score = 0.8 / 3.040.26

最后

看过计算过程之后基本上就能够确定下来ts分数低是比较正常的现象,有部分原因来自于后续处理的归一化。同时在这个探索的过程中我们看到了解它的计算原理,会结合词频、位置、字段权重等进行整体计算。这会让我们之后使用ts_rank会更加得心应手。