用户在搜索框里输入的查询经常不是最理想的检索条件:拼错了字、只打了半个词、用了口语化的说法。前一篇优化了分词和字段权重,本篇在查询到达索引之前对它做预处理——拼写纠错、前缀补全、查询改写——再加入一个与查询无关的信号:链接结构。
前缀补全
问题
用户在搜索框中输入 “java 内” 时,如果等到用户按下回车才开始搜索,就错过了一个引导用户的机会。前缀补全在用户还在打字时就列出可能的完整查询,减少输入量,同时引导用户使用索引中实际存在的词汇。
Trie 实现
最基础的前缀补全用 Trie(前缀树):
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
| class TrieNode { final Map<Character, TrieNode> children = new HashMap<>(); boolean isTerminal; int frequency; String term; }
class PrefixSuggester { private final TrieNode root = new TrieNode();
void insert(String term, int frequency) { TrieNode node = root; for (char c : term.toCharArray()) { node = node.children.computeIfAbsent(c, k -> new TrieNode()); } node.isTerminal = true; node.frequency = frequency; node.term = term; }
List<String> suggest(String prefix, int limit) { TrieNode node = root; for (char c : prefix.toCharArray()) { node = node.children.get(c); if (node == null) return List.of(); } PriorityQueue<TrieNode> heap = new PriorityQueue<>( Comparator.comparingInt(n -> n.frequency)); collectTerminals(node, heap, limit); return heap.stream() .sorted(Comparator.comparingInt((TrieNode n) -> n.frequency).reversed()) .map(n -> n.term) .toList(); }
private void collectTerminals(TrieNode node, PriorityQueue<TrieNode> heap, int limit) { if (node.isTerminal) { heap.offer(node); if (heap.size() > limit) heap.poll(); } for (TrieNode child : node.children.values()) { collectTerminals(child, heap, limit); } } }
|
构建 Trie 的数据来源:索引中所有 term 的文档频率,或者文档标题。标题比 term 更适合作为补全候选——用户搜索框中看到的应该是有意义的短语,不是被分词器拆开的单个 token。
从标题构建补全索引
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void buildSuggestionIndex(IndexReader reader) { StoredFields storedFields = reader.storedFields(); for (int i = 0; i < reader.maxDoc(); i++) { Document doc = storedFields.document(i); String title = doc.get("title"); if (title != null) { suggester.insert(title.toLowerCase(), 1); String[] words = title.split("\\s+"); for (int j = 0; j < words.length; j++) { String suffix = String.join(" ", Arrays.copyOfRange(words, j, words.length)); suggester.insert(suffix.toLowerCase(), 1); } } } }
|
Trie 的局限与 Lucene suggest 模块
Trie 的问题:
| 问题 |
影响 |
| 内存占用 |
每个字符一个节点,中文 Trie 非常宽(数千子节点) |
| 无模糊匹配 |
用户拼错一个字就找不到候选 |
| 无权重衰减 |
所有 term 权重固定,不能反映时效性 |
Lucene 的 suggest 模块用 FST(有限状态转换器)替代 Trie:
1 2 3 4 5 6 7
| AnalyzingInfixSuggester suggester = new AnalyzingInfixSuggester( FSDirectory.open(suggestPath), analyzer);
InputIterator inputIterator = new DocumentTitleIterator(reader); suggester.build(inputIterator);
List<Lookup.LookupResult> results = suggester.lookup(prefix, 10, true, false);
|
AnalyzingInfixSuggester 支持中缀匹配——输入 “检索” 可以匹配 “信息检索原理”。FST 的内存效率远高于 Trie。
教学场景先用 Trie 理解原理,再切换到 Lucene suggest 验证效果差异。
拼写纠错
编辑距离
两个字符串之间的编辑距离(Levenshtein distance)是将一个字符串变成另一个所需的最少单字符操作数(插入、删除、替换)。
1
| "serach" → "search" 编辑距离 = 1(交换 ra → ar 需要 2 步,但 serach→search 只需替换一个字符位置)
|
动态规划求解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| int editDistance(String a, String b) { int m = a.length(), n = b.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 0; i <= m; i++) dp[i][0] = i; for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (a.charAt(i-1) == b.charAt(j-1)) { dp[i][j] = dp[i-1][j-1]; } else { dp[i][j] = 1 + Math.min(dp[i-1][j-1], Math.min(dp[i-1][j], dp[i][j-1])); } } } return dp[m][n]; }
|
候选生成
对用户输入的每个 token,在索引词典中找编辑距离 ≤ 2 的所有 term:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| List<String> spellCandidates(String token, int maxDistance) { List<String> candidates = new ArrayList<>(); Terms terms = reader.terms("body"); TermsEnum termsEnum = terms.iterator(); BytesRef term; while ((term = termsEnum.next()) != null) { String termStr = term.utf8ToString(); if (editDistance(token, termStr) <= maxDistance) { candidates.add(termStr); } } candidates.sort(Comparator.comparingInt(c -> editDistance(token, c))); return candidates; }
|
暴力遍历所有 term 太慢。优化方案:
- 长度过滤:编辑距离 ≤ k 的两个字符串长度差 ≤ k
- 前缀过滤:共享前缀越长,编辑距离越小
- Lucene DirectSpellChecker:利用 term 词典的 FST 结构剪枝
1 2 3 4 5 6
| DirectSpellChecker spellChecker = new DirectSpellChecker(); spellChecker.setMaxEdits(2); spellChecker.setMinPrefix(1);
SuggestWord[] suggestions = spellChecker.suggestSimilar( new Term("body", misspelledToken), 5, reader);
|
“Did you mean” 逻辑
不是每次都纠错——只在原始查询结果太少时才提示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| SearchResult searchWithSpellCheck(String query, int page, int size) { SearchResult result = search(query, page, size, null);
if (result.totalHits() < 3) { String corrected = correctQuery(query); if (corrected != null && !corrected.equals(query)) { SearchResult correctedResult = search(corrected, page, size, null); if (correctedResult.totalHits() > result.totalHits()) { correctedResult.setSuggestion("您是不是要搜索:" + corrected); return correctedResult; } } } return result; }
|
阈值 3 是经验值:少于 3 条结果时用户很可能拼错了或用了非标准说法。
中文拼写纠错
中文没有字母级编辑距离。两种替代方案:
- 拼音编辑距离:将中文转为拼音后计算编辑距离。“信息检索” → “xinxi jiansuo”。用户可能输入 “信息捡索”("捡"和"检"同音),拼音距离为 0。
- 同音字/形近字替换表:预定义常见的混淆对。
教学场景简化处理:中文 term 只做前缀补全,不做编辑距离纠错。英文 term 用标准编辑距离。
查询改写
停用词处理
中文查询中常带有"的"、“了”、“是”、"在"等功能词。它们在几乎所有文档中都出现,对检索没有区分度,反而会拉低有意义 term 的权重。
1 2 3 4 5 6 7 8 9 10
| Set<String> STOP_WORDS = Set.of("的", "了", "是", "在", "和", "与", "及", "将", "对", "中", "为", "这", "那", "有", "不", "也", "都", "就", "the", "a", "an", "is", "are", "was", "were", "in", "on", "at", "to", "for", "of", "with", "by");
String removeStopWords(String query) { return Arrays.stream(query.split("\\s+")) .filter(w -> !STOP_WORDS.contains(w.toLowerCase())) .collect(Collectors.joining(" ")); }
|
Lucene 分析器链中也可以加入 StopFilter 实现同样的效果。区别在于:查询改写阶段去停用词可以在用户界面展示改写后的查询;分析器层面去停用词对用户不可见。
查询松弛
当原始查询返回零结果时,逐步放松条件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| SearchResult searchWithRelaxation(String query, int page, int size) { SearchResult result = search(query, page, size, null); if (result.totalHits() > 0) return result;
String relaxed = removeStopWords(query); if (!relaxed.equals(query)) { result = search(relaxed, page, size, null); if (result.totalHits() > 0) { result.setNote("已忽略部分常见词"); return result; } }
String noPhrase = query.replace("\"", ""); if (!noPhrase.equals(query)) { result = search(noPhrase, page, size, null); if (result.totalHits() > 0) { result.setNote("已取消短语限制"); return result; } }
return result; }
|
改写候选限制
每个查询最多生成 3 个改写变体。理由:
- 过多变体会召回大量噪声文档
- 用户无法理解搜索引擎为什么返回了看似无关的结果
- 改写应该是对用户意图的合理猜测,不是暴力扩展
链接质量信号:PageRank
从链接图提取结构
第 04-05 篇爬取的网页中包含 <a href> 链接。这些链接构成一个有向图:页面 A 链接到页面 B 表示 A 的作者认为 B 有参考价值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| class LinkGraph { private final Map<String, Integer> urlToId = new HashMap<>(); private final List<String> idToUrl = new ArrayList<>(); private final List<List<Integer>> outLinks = new ArrayList<>(); private final List<List<Integer>> inLinks = new ArrayList<>();
int addUrl(String url) { return urlToId.computeIfAbsent(url, u -> { int id = idToUrl.size(); idToUrl.add(u); outLinks.add(new ArrayList<>()); inLinks.add(new ArrayList<>()); return id; }); }
void addLink(String from, String to) { int fromId = addUrl(from); int toId = addUrl(to); outLinks.get(fromId).add(toId); inLinks.get(toId).add(fromId); } }
|
核心公式:
1
| PR(i) = (1-d)/N + d × Σ_{j→i} PR(j)/OutDegree(j)
|
d = 0.85 是标准阻尼因子。含义:用户有 85% 的概率沿着链接继续浏览,15% 的概率跳到随机页面。
幂迭代实现:
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
| double[] computePageRank(LinkGraph graph, double d, double epsilon, int maxIter) { int N = graph.size(); double[] pr = new double[N]; double[] next = new double[N]; Arrays.fill(pr, 1.0 / N);
for (int iter = 0; iter < maxIter; iter++) { double danglingSum = 0; for (int j = 0; j < N; j++) { if (graph.outDegree(j) == 0) { danglingSum += pr[j]; } }
for (int i = 0; i < N; i++) { next[i] = (1 - d) / N + d * danglingSum / N; for (int j : graph.inLinks(i)) { next[i] += d * pr[j] / graph.outDegree(j); } }
double diff = 0; for (int i = 0; i < N; i++) { diff += Math.abs(next[i] - pr[i]); }
double[] temp = pr; pr = next; next = temp;
if (diff < epsilon) { break; } } return pr; }
|
Dangling nodes
没有出链的页面(如 PDF、图片页面)会让 PageRank 质量"泄漏"。上面的实现将 dangling node 的 PR 均匀分配给所有页面——等价于这些页面链接到所有页面。
收敛性
幂迭代在 Web 规模的图上通常 50-100 次迭代收敛。教学语料的小图(数百到数千节点)10-20 次就够了。
方案比较
| 方案 |
公式 |
优点 |
缺点 |
| 线性加权 |
α × BM25 + β × log(PR) |
简单 |
BM25 和 PR 量纲不同,α/β 难调 |
| 乘性融合 |
BM25 × (1 + γ × log(PR)) |
PR 只做微调 |
γ 敏感 |
| Lucene FeatureField |
索引时存储静态分数,查询时用 FeatureQuery boost |
与 Lucene 评分框架集成 |
需要额外字段 |
教学场景用 Lucene 的 FeatureField:
1 2 3 4 5 6 7 8 9 10 11
| document.add(new FeatureField("features", "pagerank", (float) pageRankScore));
Query textQuery = buildTextQuery(queryStr); Query prBoost = FeatureField.newLogQuery("features", "pagerank", 1.0f, 1.0f);
BooleanQuery finalQuery = new BooleanQuery.Builder() .add(textQuery, BooleanClause.Occur.MUST) .add(prBoost, BooleanClause.Occur.SHOULD) .build();
|
FeatureField.newLogQuery 对 PageRank 取对数后作为 boost 加入总分。对数压缩了 PageRank 的跨度——最高和最低 PR 的页面之间可能差几个数量级,直接用原始值会让 PR 完全主导评分。
改善与误伤分析
在开发集上比较加入 PageRank 前后的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void analyzePageRankImpact(QuerySet devSet) { for (AnnotatedQuery q : devSet.queries()) { List<String> beforeTop5 = searchTop5(q.text(), false); List<String> afterTop5 = searchTop5(q.text(), true);
if (!beforeTop5.equals(afterTop5)) { double beforeNdcg = computeNdcg(beforeTop5, q.relevance(), 10); double afterNdcg = computeNdcg(afterTop5, q.relevance(), 10);
String verdict = afterNdcg > beforeNdcg ? "改善" : afterNdcg < beforeNdcg ? "误伤" : "持平"; System.out.printf("%s: '%s' nDCG %.4f → %.4f\n", verdict, q.text(), beforeNdcg, afterNdcg); } } }
|
预期结果:
- 改善案例:权威页面(被多个页面链接)在多个相关文档竞争时排名提升
- 误伤案例:内容高度相关但链接数少的页面(如新发布的文章)被链接数多但内容一般的页面挤掉
- 教学语料的链接图可能很稀疏,PageRank 的区分度可能不明显
验证
| 场景 |
操作 |
预期 |
| 前缀补全 |
输入 “java 内” |
返回含 “java 内存” 的候选 |
| 拼写纠错 |
搜索 “serach engine” |
提示 “您是不是要搜索:search engine” |
| 零结果松弛 |
搜索 “高性能的并发框架” |
去停用词后搜索,返回并发相关结果 |
| PageRank 生效 |
搜索通用概念 |
被广泛链接的页面排名靠前 |
| 无点击数据 |
所有功能 |
全部可复现,不依赖用户行为数据 |
| 改写限制 |
任何查询 |
最多 3 个改写变体 |
当前局限
- 前缀补全没有个性化——所有用户看到相同的候选
- 拼写纠错只处理英文 term,中文依赖拼音方案(本篇未实现)
- 查询改写是基于规则的,没有学习机制
- PageRank 在小链接图上的区分度有限
- 没有点击数据做反馈——生产搜索引擎最重要的信号恰恰是点击
练习
- 用索引中所有标题构建 Trie,输入 5 个前缀测试补全结果
- 对 “javaa”、“seach”、“indx” 三个拼错词生成纠错候选
- 从爬取的网页中提取链接图,运行 PageRank 至收敛,打印 Top-10 页面
- 比较加入 PageRank 前后开发集上 5 个查询的排序变化
- 构造一个查询改写反而有害的案例,记录原因
延伸阅读
- Introduction to Information Retrieval, Ch 21: Link analysis
- Brin & Page, “The Anatomy of a Large-Scale Hypertextual Web Search Engine”, 1998
- Apache Lucene: AnalyzingInfixSuggester Javadoc
- Apache Lucene: DirectSpellChecker Javadoc
- Apache Lucene: FeatureField Javadoc