最近在使用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_AND 或 OP_PHRASE),选择 calc_rank_and 或 calc_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 (禁用标准化) | 需要绝对分数的场景 | |
| 1 | RANK_NORM_LOGLENGTH | 1 + log(文档词位数) | 默认选项,通用场景 | |
| 2 | RANK_NORM_LENGTH | 文档词位数 | 强长度惩罚 | |
| 4 | RANK_NORM_LOGDOCLEN | 1 + log(1 + 文档词位数/平均长度) | 已弃用 | |
| 8 | RANK_NORM_DOCLEN | (文档词位数/平均长度) | 已弃用 | |
| 16 | RANK_NORM_EXTDIST | 额外考虑词项距离 | 短语搜索优化 | |
| 1 | 16 (17) | 组合选项 | (1 + log(长度)) * 距离因子 | 精确短语匹配 |
假设有两个文档,一个文档的词位数是5,一个文档的词位数是20,在长度标准化之前两个文档计算的最终分数都是0.8,使用默认标准化。那么他们的最终得分如下:
# 文档1 (5词)
length_norm = log(1 + 5) ≈ 1.79
final_score = 0.8 / 1.79 ≈ 0.45
# 文档2 (20词)
length_norm = log(1 + 20) ≈ 3.04
final_score = 0.8 / 3.04 ≈ 0.26
最后
看过计算过程之后基本上就能够确定下来ts分数低是比较正常的现象,有部分原因来自于后续处理的归一化。同时在这个探索的过程中我们看到了解它的计算原理,会结合词频、位置、字段权重等进行整体计算。这会让我们之后使用ts_rank会更加得心应手。