搜索引擎返回了 Top-10 结果,每条是一个 docID 和 BM25 分数。用户看到的却不应该是 doc#4237, score=2.091。用户需要标题、URL、一段能说明"为什么这条结果和查询相关"的摘要,以及查询词在摘要中的高亮。
本篇在搜索结果之上补齐四项能力:查询相关摘要、关键词高亮、HTML 转义(防止 XSS)、以及翻页。
存储字段
倒排索引不存原文
倒排索引记录的是"词项 → 文档列表",查询时能找到匹配的 docID 和词频,但无法还原文档原文。摘要和高亮需要原文。
两种方案:
- 存储字段(Stored Fields):索引时把需要展示的字段(标题、URL、正文)存入独立的正向存储。按 docID 随机访问。
- 外部存储:把原文放在数据库或文件系统中,用 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<>();
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; } 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("&"); case '<' -> sb.append("<"); case '>' -> sb.append(">"); case '"' -> sb.append("""); case '\'' -> sb.append("'"); default -> sb.append(c); } } return sb.toString(); }
|
转义与高亮的顺序
高亮标签(<em>)不应该被转义。正确顺序:
- 从存储字段读取原文
- 提取摘要
- 先转义 HTML 特殊字符
- 再插入 高亮标签
但这有一个问题:转义后查询词的 offset 会偏移(“&” 变成 “&” 多了 4 个字符)。解决方法用占位符:
1 2 3 4 5 6
| String snippetWithMarkers = highlightWithMarkers(snippet, queryTerms);
String escaped = escapeHtml(snippetWithMarkers);
String result = escaped.replace("", "<em>").replace("", "</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) { }
|
优势:无论翻到第几页,成本恒定——始终只计算 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> 被转义为 <script>,高亮标签正常渲染 |
| 翻页不重复 |
第 2 页的结果不与第 1 页重复 |
| 翻页不遗漏 |
第 1 页最后一条的下一条是第 2 页第一条 |
| 快照过期 |
返回明确的提示而非错误结果 |
当前局限
- 摘要提取在查询时实时计算,对长文档有延迟——生产系统会预计算或缓存
- 高亮基于字符串匹配而非分析器 token——可能与实际检索的匹配不一致
- 存储字段没有压缩——Lucene 使用 LZ4 压缩存储字段
- 分页没有实现游标过期清理——长时间不翻页的快照应该释放
- 没有实现多字段摘要——只从正文提取,标题和 URL 单独展示
练习
- 构造一篇文档,查询词在第 1,000 个字符处,验证摘要截取到该位置附近
- 在文档中插入
<img src=x onerror=alert(1)>,验证转义后浏览器不执行
- 用 search-after 翻 5 页,验证无重复无遗漏
- 比较 offset-based 分页在第 1 页和第 100 页的耗时差异
- 构造一个 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