搜索引擎返回了 Top-10 结果,每条是一个 docID 和 BM25 分数。用户看到的却不应该是 doc#4237, score=2.091。用户需要标题、URL、一段能说明"为什么这条结果和查询相关"的摘要,以及查询词在摘要中的高亮。

本篇在搜索结果之上补齐四项能力:查询相关摘要、关键词高亮、HTML 转义(防止 XSS)、以及翻页。

存储字段

倒排索引不存原文

倒排索引记录的是"词项 → 文档列表",查询时能找到匹配的 docID 和词频,但无法还原文档原文。摘要和高亮需要原文。

两种方案:

  1. 存储字段(Stored Fields):索引时把需要展示的字段(标题、URL、正文)存入独立的正向存储。按 docID 随机访问。
  2. 外部存储:把原文放在数据库或文件系统中,用 docID 查询时回查。

教学实现用存储字段——把标题、URL、正文摘要在索引时写入一个按 docID 排列的文件,查询时按 offset 定位。

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
record StoredDocument(String title, String url, String content) {}

class StoredFieldsWriter {
private final DataOutputStream out;
private final List<Long> offsets = new ArrayList<>(); // docId → file offset

void writeDoc(int docId, StoredDocument doc) throws IOException {
offsets.add(out.size());
writeString(out, doc.title());
writeString(out, doc.url());
writeString(out, doc.content());
}
}

class StoredFieldsReader {
private final RandomAccessFile raf;
private final long[] offsets;

StoredDocument readDoc(int docId) throws IOException {
raf.seek(offsets[docId]);
String title = readString(raf);
String url = readString(raf);
String content = readString(raf);
return new StoredDocument(title, url, content);
}
}

Lucene 的实现更复杂——使用压缩的 chunk 存储多个文档,减少 I/O 次数。但对教学数据量,每个文档一次 seek 够用。

查询相关摘要

问题

文档可能有 2,000 字,搜索结果只展示 200 字的摘要。截取文档开头是最简单的做法,但不是最好的——如果查询词出现在文档中间,用户看不到它们在摘要中出现,无法判断相关性。

Query-Biased Snippet

找到查询词在文档中出现最密集的区域,截取该区域作为摘要:

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
String extractSnippet(String content, List<String> queryTerms, int maxLen) {
// 找到所有查询词的出现位置
List<int[]> hits = new ArrayList<>();
String lower = content.toLowerCase();
for (String term : queryTerms) {
String termLower = term.toLowerCase();
int idx = 0;
while ((idx = lower.indexOf(termLower, idx)) >= 0) {
hits.add(new int[]{idx, idx + term.length()});
idx += term.length();
}
}

// 无命中时返回开头
if (hits.isEmpty()) {
return content.substring(0, Math.min(maxLen, content.length()));
}

// 滑动窗口找最密集区域
int bestStart = 0, bestCount = 0;
for (int[] hit : hits) {
int winStart = Math.max(0, hit[0] - maxLen / 2);
int winEnd = Math.min(content.length(), winStart + maxLen);
int count = 0;
for (int[] h : hits) {
if (h[0] >= winStart && h[1] <= winEnd) count++;
}
if (count > bestCount) {
bestCount = count;
bestStart = winStart;
}
}

// 调整到词边界(对中文不需要,对英文找最近的空格)
int end = Math.min(content.length(), bestStart + maxLen);
String snippet = content.substring(bestStart, end);
String prefix = bestStart > 0 ? "…" : "";
String suffix = end < content.length() ? "…" : "";
return prefix + snippet + suffix;
}

时间复杂度 O(H × H),H 是查询词总出现次数。对教学规模(几千字的文档)足够快。

中文摘要的边界问题

英文需要在词边界截断——避免截到单词中间(“informati…”)。中文每个字都是自然边界,不需要额外处理。但要注意:不要截断到 UTF-16 surrogate pair 中间(emoji 或生僻字可能占两个 char)。

1
2
3
4
5
6
7
int adjustBoundary(String content, int pos) {
if (pos >= content.length()) return content.length();
if (Character.isHighSurrogate(content.charAt(pos))) {
return pos; // 不截断 surrogate pair
}
return pos;
}

关键词高亮

Offset-Based Highlighting

在摘要文本中找到查询词的位置,用 HTML 标签包裹:

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
String highlight(String snippet, List<String> queryTerms) {
// 收集所有匹配区间
List<int[]> spans = new ArrayList<>();
String lower = snippet.toLowerCase();
for (String term : queryTerms) {
String termLower = term.toLowerCase();
int idx = 0;
while ((idx = lower.indexOf(termLower, idx)) >= 0) {
spans.add(new int[]{idx, idx + term.length()});
idx += term.length();
}
}

if (spans.isEmpty()) return snippet;

// 排序并合并重叠区间
spans.sort(Comparator.comparingInt(a -> a[0]));
List<int[]> merged = new ArrayList<>();
merged.add(spans.get(0));
for (int i = 1; i < spans.size(); i++) {
int[] last = merged.get(merged.size() - 1);
int[] curr = spans.get(i);
if (curr[0] <= last[1]) {
last[1] = Math.max(last[1], curr[1]);
} else {
merged.add(curr);
}
}

// 从后往前插入标签
StringBuilder sb = new StringBuilder(snippet);
for (int i = merged.size() - 1; i >= 0; i--) {
sb.insert(merged.get(i)[1], "</em>");
sb.insert(merged.get(i)[0], "<em>");
}
return sb.toString();
}

从后往前插入是关键——如果从前往后,每次插入会改变后续字符的 offset,后面的插入位置全部错位。

CJK Bigram 与高亮

Bigram 分词下,查询 “搜索” 在文档 “搜索引擎” 中匹配。但文档原文是完整的四字词——高亮应该只标记匹配的两个字 “搜索”,而不是整个 “搜索引擎”。

如果分析器在索引时记录了每个 token 的 startOffset 和 endOffset(第 06 篇),高亮可以直接用这些 offset 而非在原文中重新搜索。这避免了二次分词可能引入的不一致。

HTML 转义

XSS 风险

摘要和高亮最终嵌入 HTML 页面。如果文档内容包含 <script>alert('xss')</script>,不转义就会在用户浏览器中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String escapeHtml(String text) {
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
switch (c) {
case '&' -> sb.append("&amp;");
case '<' -> sb.append("&lt;");
case '>' -> sb.append("&gt;");
case '"' -> sb.append("&quot;");
case '\'' -> sb.append("&#39;");
default -> sb.append(c);
}
}
return sb.toString();
}

转义与高亮的顺序

高亮标签(<em>)不应该被转义。正确顺序:

  1. 从存储字段读取原文
  2. 提取摘要
  3. 先转义 HTML 特殊字符
  4. 再插入 高亮标签

但这有一个问题:转义后查询词的 offset 会偏移(“&” 变成 “&” 多了 4 个字符)。解决方法用占位符:

1
2
3
4
5
6
String snippetWithMarkers = highlightWithMarkers(snippet, queryTerms);
// "...Java \x01GC\x02 调优..."
String escaped = escapeHtml(snippetWithMarkers);
// \x01 不是 HTML 特殊字符,转义不影响
String result = escaped.replace("", "<em>").replace("", "</em>");
// "...Java <em>GC</em> 调优..."

占位符(\x01\x02)不是 HTML 特殊字符,转义不影响它们。最后替换为真正的 HTML 标签。

分页

Offset-Based 分页

1
2
3
第 1 页:Top-10,返回第 1-10
第 2 页:Top-20,丢弃前 10 条,返回第 11-20
第 3 页:Top-30,丢弃前 20 条,返回第 21-30

实现简单,但第 N 页需要计算 Top-(N×size) 条结果,越翻越慢。

1
2
3
4
5
6
7
8
9
SearchResult search(String query, int page, int size) {
int total = page * size;
List<ScoredDoc> topN = searchTopK(query, total);
int from = (page - 1) * size;
List<ScoredDoc> pageResults = topN.subList(
Math.min(from, topN.size()),
Math.min(total, topN.size()));
return new SearchResult(pageResults, topN.size() >= total);
}

Search-After 分页

上一页最后一条结果的 (score, docId) 作为下一页的起点:

1
2
3
4
5
SearchResult searchAfter(String query, double lastScore, int lastDocId, int size) {
// 在 Top-K 算法中,跳过 score > lastScore 的文档
// 以及 score == lastScore && docId <= lastDocId 的文档
// 只取后续的 size 条
}

优势:无论翻到第几页,成本恒定——始终只计算 size 条结果。

劣势:不能跳到任意页;如果两次请求之间索引发生变化(新增或删除文档),可能出现重复或遗漏。

固定快照

为避免翻页过程中索引变化导致结果漂移,每次搜索返回一个版本号(commit generation)。翻页时带上版本号,使用相同的索引快照。

1
2
3
4
5
record SearchResult(
List<ResultItem> items,
boolean hasMore,
long snapshotVersion // commit generation
) {}

教学实现中,快照就是一组段文件引用。只要这些段文件没有被物理删除(段合并的清理延迟),快照就有效。

结果项组装

把以上组件整合,一条搜索结果的完整渲染流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
record ResultItem(
String title,
String url,
String snippet,
double score
) {}

ResultItem renderResult(int docId, double score, List<String> queryTerms) {
StoredDocument doc = storedFields.readDoc(docId);
String snippet = extractSnippet(doc.content(), queryTerms, 200);
String marked = highlightWithMarkers(snippet, queryTerms);
String escaped = escapeHtml(marked);
String highlighted = escaped.replace("", "<em>").replace("", "</em>");

String titleMarked = highlightWithMarkers(doc.title(), queryTerms);
String titleEscaped = escapeHtml(titleMarked);
String titleHighlighted = titleEscaped.replace("", "<em>").replace("", "</em>");

return new ResultItem(titleHighlighted, escapeHtml(doc.url()), highlighted, score);
}

验证

场景 预期行为
查询词在文档中间 摘要截取到查询词附近,而非文档开头
多个查询词分散 摘要选择查询词最密集的区域
高亮 offset <em> 标签准确包裹查询词,不多不少
中文高亮 “搜索” 在 “搜索引擎” 中只高亮前两个字
XSS 内容 <script> 被转义为 &lt;script&gt;,高亮标签正常渲染
翻页不重复 第 2 页的结果不与第 1 页重复
翻页不遗漏 第 1 页最后一条的下一条是第 2 页第一条
快照过期 返回明确的提示而非错误结果

当前局限

  • 摘要提取在查询时实时计算,对长文档有延迟——生产系统会预计算或缓存
  • 高亮基于字符串匹配而非分析器 token——可能与实际检索的匹配不一致
  • 存储字段没有压缩——Lucene 使用 LZ4 压缩存储字段
  • 分页没有实现游标过期清理——长时间不翻页的快照应该释放
  • 没有实现多字段摘要——只从正文提取,标题和 URL 单独展示

练习

  1. 构造一篇文档,查询词在第 1,000 个字符处,验证摘要截取到该位置附近
  2. 在文档中插入 <img src=x onerror=alert(1)>,验证转义后浏览器不执行
  3. 用 search-after 翻 5 页,验证无重复无遗漏
  4. 比较 offset-based 分页在第 1 页和第 100 页的耗时差异
  5. 构造一个 bigram 高亮案例:查询 “索引” 在 “搜索引擎” 中不应高亮(“索引” 的 bigram 是 “索引”,与 “搜索引擎” 的 bigram 序列 “搜索/索引/引擎” 有交集——讨论这是否算误高亮)

延伸阅读

  • Tombardi, A. et al. (2003). Document Summarization for Search Engines. Information Retrieval.
  • Lucene 源码:org.apache.lucene.search.highlight.Highlighter
  • Lucene 源码:org.apache.lucene.index.StoredFields
  • OWASP XSS Prevention Cheat Sheet