欢迎光临
我们一直在努力

【DIY系列:自己动手写分布式搜索引擎】第16篇:查询入门:从倒排链到结果集

上一篇【第15篇】读入索引文件:怎么把磁盘数据读回来 下一篇【第17篇】查询加速:跳表与倒排链求交的艺术


摘要

前面五篇,我们从读文档、分词、建倒排、设计格式、安全落盘到读回内存,把索引这一头彻底打通了。现在轮到搜索引擎真正存在的意义:查。用户敲一个词,你怎么从倒排链里捞出相关文档,再多词时怎么取交集,结果凭什么排前排后。这一篇把查询的最小闭环跑通,给你第一个能跑的查询 demo。看到结果按相关性排好队,你会第一次有"我真的写了个搜索引擎"的实感。

一、查询的本质:从词找回文档

建索引时我们干的是"文档 → 词"的正向活;查询正好反过来,“词 → 文档”。倒排索引就是为此而生的:给一个词,直接查它的倒排链,链上每个 docId 就是命中文档。

单词查询简单到有点无聊:

用户输入: "耳机"
查词典: "耳机" → 倒排链 [1, 2, 4]
结果: 文档 1、2、4 都含"耳机"

这一步的时间复杂度是 O(1) 查词典 + O(k) 遍历倒排链(k 是命中数)。哪怕亿级文档,只要"耳机"只命中几千篇,查询也是毫秒级。这正是倒排索引快得"不讲道理"的原因:它把"全表扫描"变成了"按词查表"。

二、多词查询:取交集做 AND

真实搜索几乎都是多个词。比如"无线 耳机",用户想要的是"既含无线又含耳机"的文档。逻辑就是:分别取两条倒排链,求交集。

用户查询: "无线 耳机"
取链: "无线" → [1, 3]
"耳机" → [1, 2, 4]
求交集: [1, 3] ∩ [1, 2, 4] = [1]
结果: 只有文档 1 同时含两个词

如果倒排链有序(我们建索引时就是按 docId 递增加入的),交集用双指针线性扫描就能做,不用额外开 HashSet,省内存又快。

import java.util.*;

/** 对两条有序倒排链求交集(AND) */
public class Intersect {

public static List<Integer> and(List<Integer> a, List<Integer> b) {
List<Integer> result = new ArrayList<>();
int i = 0, j = 0;
while (i < a.size() && j < b.size()) {
int x = a.get(i), y = b.get(j);
if (x == y) {
result.add(x);
i++; j++;
} else if (x < y) {
i++;
} else {
j++;
}
}
return result;
}
}

重点:倒排链必须有序,交集才能用双指针 O(n+m) 扫完。如果哪天你的倒排链没排序,交集会退化成哈希集合,内存和速度都吃亏。建索引时保持 docId 有序,是这个优化免费的来源。

三、打分:结果凭什么排前排后

交集只解决了"要不要",没解决"谁靠前"。搜索引擎的结果是排好序的,靠的是打分。我们手写版先用最朴素的 TF(词频)加权,把原理跑通,BM25 后面再升级。

想法很直白:一个词在某文档里出现越多,这文档越相关。多词查询时,把每个命中词的 tf 加起来当总分。

/** 极简打分:累加每个命中词的 tf */
public class SimpleScorer {

public Map<Integer, Float> score(String[] terms, MiniIndexReader reader) throws IOException {
Map<Integer, Float> scores = new HashMap<>();
for (String term : terms) {
List<Posting> postings = reader.getPostings(term.toLowerCase());
for (Posting p : postings) {
scores.merge(p.getDocId(), (float) p.getTf(), Float::sum);
}
}
return scores;
}
}

查"无线 耳机",文档1在"无线"里 tf=1、“耳机"里 tf=1,总分 2;文档3只含"无线"总分 1。排序后文档1 自然排前。打分越准,用户越觉得"这搜索懂我”。

四、组装成查询 demo:第一个能跑的 Searcher

把读词典、求交、打分、排序串起来,一个最小 Searcher 就成型了。

import java.util.*;

public class MiniSearcher {

private final MiniIndexReader reader;

public MiniSearcher(MiniIndexReader reader) { this.reader = reader; }

public List<SearchHit> search(String query) throws IOException {
// 1. 分词(和建索引用同一套 analyzer!)
SimpleAnalyzer analyzer = new SimpleAnalyzer();
List<String> terms = analyzer.analyze(query);

// 2. 取每条倒排链, 逐个求交集(AND)
List<Integer> docIds = null;
Map<Integer, Float> scores = new HashMap<>();
for (String term : terms) {
List<Integer> chain = new ArrayList<>();
for (Posting p : reader.getPostings(term)) {
chain.add(p.getDocId());
scores.merge(p.getDocId(), (float) p.getTf(), Float::sum);
}
if (docIds == null) {
docIds = chain;
} else {
docIds = Intersect.and(docIds, chain); // 与剩余结果求交
}
}
if (docIds == null) docIds = Collections.emptyList();

// 3. 按分数降序排
List<SearchHit> hits = new ArrayList<>();
for (int id : docIds) {
hits.add(new SearchHit(id, scores.getOrDefault(id, 0f)));
}
hits.sort((a, b) -> Float.compare(b.score, a.score));
return hits;
}

public static class SearchHit {
public final int docId; public final float score;
public SearchHit(int docId, float score) { this.docId = docId; this.score = score; }
public String toString() { return "doc=" + docId + ", score=" + score; }
}
}

跑一下我们的样例语料:

MiniIndexReader reader = new MiniIndexReader(new File("index/dictionary.dat"));
MiniSearcher searcher = new MiniSearcher(reader);
System.out.println(searcher.search("无线 耳机"));
System.out.println(searcher.search("降噪"));

预期:

[doc=1, score=2.0, doc=3, score=1.0] // "无线 耳机" 文档1同时含两词排前
[doc=1, score=1.0, doc=4, score=1.0] // "降噪" 命中 1 和 4

从输入一串字,到拿到排好序的命中列表,这就是一个搜索引擎最核心的闭环。你刚刚亲手跑通了它。

【一次查询的完整链路】

用户输入 "无线 耳机"
│ 分词
▼
["无线", "耳机"]
│ 查倒排
▼
"无线"→[1,3] "耳机"→[1,2,4]
│ 求交集
▼
[1]
│ 打分排序
▼
[doc=1, score=2.0]

五、查询里最容易翻车的坑

讲几个我见过的真实翻车:

  • 建索引和查 query 用了不同分词器。建的时候中文按空格切,查的时候按字切,结果词对不上,查啥都空。二者必须同一套规则,前面反复强调不是没道理。
  • 倒排链没排序就求交。HashSet 一上,内存和速度双双翻车,语料稍大就卡。
  • 忘了处理空查询。用户敲个空格就回车,terms 为空,交集逻辑要兜底返回空,别抛空指针。

六、这一篇你跑通了什么

到这,索引的"写"和"读",加上查询的"交"和"排",四个环节全通了。你手里已经是一个能用的迷你搜索引擎:建索引、落盘、读回、查词、求交、打分、排序,一气呵成。

但交集用的是朴素的双指针线性扫描。一旦某个词命中十万文档,两条长链求交会扫得你怀疑人生。下一篇,我们请出跳表(SkipList),教倒排链"跳着比",把长链求交的速度拉起来。

七、为什么排序用分数而不是"命中个数"

你可能会想:一个词命中两次、另一个命中一次,直接按命中次数排不就行了?问题在"长文档天然词多"。一篇 5000 字的长文里"耳机"出现 5 次,一篇 100 字的短文里出现 2 次,你能说长文更相关吗?未必,可能只是因为它长。

所以朴素 TF 累加已经是个粗略修正,但还不够。工业界用 BM25 时还会做"文档长度归一":同样出现 2 次,短文档里的 2 次比长文档里的 2 次更说明问题。我们手写版先用 TF 累加把链路跑通,等你钻进 Lucene 再换成 BM25,那时你会明白这几个归一项为什么必不可少。

八、多词查询的两种打分策略

多词查询打分有两种取向,值得提前知道:

  • 求和策略:每个命中词贡献的 tf 相加,我们上面就是这种。简单,但会偏向包含很多词的文档。
  • 乘性/协调策略:要求文档命中越多词越好,漏掉一个词就大幅降分。BooleanQuery 里的 coord 因子就是这个意思,能惩罚"只命中部分词"的结果。

我们手写版用求和足够演示原理。真做业务时,这两种取向配合"词权重"(标题里的词比正文里的词重要),才是相关性调优的主战场。

九、查询层的边界感

到这,查询层已经是一个干净的小模块:它只干三件事,查倒排、求交、打分排序,不关心索引怎么建、怎么存。这种"只问倒排链、不问底层"的边界,让我们可以随时把底层从内存 HashMap 换成 Lucene,查询逻辑几乎不动。好的搜索引擎架构,就是这样一层层把复杂度隔离出来的。

本篇小结

查询的本质是"词 → 文档"的逆过程:单词查倒排链即得命中,多词用有序双指针求交集做 AND,再按 TF 累加打分排序。本文把读索引、求交、打分、排序串成第一个能跑的 Searcher,跑出了 [doc=1, score=2.0] 这种实打实的结果。但长倒排链线性求交会慢,下一篇用跳表让倒排链跳着比,提速明显。

上一篇【第15篇】读入索引文件:怎么把磁盘数据读回来 下一篇【第17篇】查询加速:跳表与倒排链求交的艺术


赞(0)
未经允许不得转载:171主机测评 » 【DIY系列:自己动手写分布式搜索引擎】第16篇:查询入门:从倒排链到结果集
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址