Apache Zeppelin 搜索体系全解析:从文档站 Lunr 全文检索到 Notebook 语义搜索
Apache Zeppelin 搜索体系全解析:从文档站 Lunr 全文检索到 Notebook 语义搜索
Apache Zeppelin 项目包含两条并行的搜索链路:面向文档站的静态站内搜索(基于 Lunr.js 的客户端全文检索),以及面向 Notebook 内容的服务端搜索(基于 SearchService 抽象的 Lucene 关键词检索与嵌入向量语义检索)。本文以 docs/search.md 页面为入口,深入其背后的索引生成、客户端检索脚本与服务端实现,帮助读者掌握 Zeppelin 中"搜文档"与"搜笔记"两套能力的完整工作原理与配置方式。
一、搜索体系的两层结构:文档站与 Notebook
Zeppelin 的搜索功能分布在两个截然不同的技术栈中,理解它们各自的边界是使用与二次开发的前提:
| 维度 | 文档站搜索 | Notebook 搜索 |
|---|---|---|
| 入口页面 | docs/search.md | Notebook UI 的搜索框 / NotebookRestApi.java |
| 数据来源 | Jekyll 生成的 search_data.json | 服务端对 Notebook 段落建立的索引 |
| 检索引擎 | 客户端 Lunr.js(纯前端,无需服务器) | 服务端 SearchService(Lucene 或 Embedding) |
| 是否在线 | 完全离线、静态 | 需要 Zeppelin Server 运行 |
文档站搜索服务于 Apache Zeppelin 官方文档的站内检索,属于纯静态方案;Notebook 搜索则是 Zeppelin Server 的核心业务能力之一。下文先拆解文档站搜索的实现细节,再深入服务端语义搜索的架构与配置。
二、文档站搜索页:search.md 的页面骨架
docs/search.md 是 Jekyll 渲染的一个页面模板,除去 Apache License 头与 front-matter(layout: page),其核心结构只有三部分:
- 标题区:
<span id="search">内的 "Search Docs" 提示; - 搜索表单:
<form role="search" action="{{BASE_PATH}}/search.html" id="site_search">,其中{{BASE_PATH}}由 docs/_config.yml 中的JB.BASE_PATH注入(当前配置为/docs/0.13.0-SNAPSHOT); - 结果容器:
<div id="search_results"></div>,由 JavaScript 动态填充。
表单内包含一个输入框(id="search_box",placeholder 为 "Search all pages")和一个重置按钮(type="reset"),负责清空输入。该页面本身不做任何检索逻辑——真正的索引与检索全部由客户端脚本完成,页面只提供 DOM 挂载点。
三、索引数据源:search_data.json 的生成机制
文档站检索的全部数据来自 docs/search_data.json。该文件并非手写,而是由 Jekyll 在构建站点时,遍历 site.pages 中所有带 title 的页面动态生成的,其模板逻辑位于文件内:
- 以
page.url | slugify作为每个文档的唯一键(该键同时作为 Lunr 文档的id); - 字段包含
title、content(经strip_html/strip_newlines/escape清洗后的正文)、url、group、excerpt(由description截断 40 词生成)。
这意味着:任何新增的文档页面只要设置了 title 与 description,无需额外注册即可自动进入搜索索引;同时正文中的 HTML 标签、换行、引号都会被清理,避免污染 JSON 与检索结果。
四、客户端检索实现:search.js 与 Lunr 索引配置
文档站搜索的真正执行者是 docs/assets/themes/zeppelin/js/search.js,配合 lunr-0.7.0.min.js。其工作流程如下:
1. 构建索引(页面加载时执行)
window.idx = lunr(function () {
this.field('id');
this.field('title');
this.field('content', { boost: 10 });
this.field('group');
});
索引对 title、content、group 三个字段建立全文索引,其中 content 字段权重被提升到 10,意味着正文命中比标题命中得分更高,这是检索排序的核心依据。
2. 载入数据并注入索引
window.data = $.getJSON('search_data.json');
window.data.then(function(loaded_data){
$.each(loaded_data, function(index, value){
window.idx.add($.extend({ "id": index }, value));
});
});
通过 jQuery 异步加载 search_data.json,把每个键值对以 id 为主键加入 Lunr 索引。注意 search_data.json 的相对路径——它是相对于当前页面(/docs/.../search.html)的,而并非相对仓库根目录,部署时由 Jekyll 的 BASE_PATH 机制保证可用。
3. 实时检索与结果渲染
$("#site_search").keyup(function(event){
event.preventDefault();
var query = $("#search_box").val();
var results = window.idx.search(query);
display_search_results(results);
});
输入框每次 keyup 都触发一次检索(无需回车),同时脚本在 $('html').bind('keypress', ...) 中拦截了 Enter 键(keyCode == 13 返回 false),避免表单提交刷新页面。渲染阶段:
- 有结果时,先输出
Found N result(s),随后逐条追加:标题链接(<a href="base_url + item.url">)、完整 URL(prod_url + base_url + item.url)与excerpt摘要; - 无结果时,给出提示文案 "Your search did not match any documents...",引导用户检查拼写或使用更宽泛的关键词。
4. 脚本装配
docs/_includes/themes/zeppelin/default.html 在 <head> 中按顺序加载 jquery-1.10.2.min.js → bootstrap.min.js → docs.js → anchor-1.1.1.min.js → toc.js → lunr-0.7.0.min.js → search.js。其中 search.js 必须位于 lunr 与 jquery 之后加载,否则 lunr 与 jQuery 全局对象不可用。这一加载顺序是文档站搜索可用的硬性前提。
五、服务端 Notebook 搜索:SearchService 抽象层
与文档站搜索不同,Zeppelin 的 Notebook 搜索运行在服务端,由 SearchService.java 定义统一抽象。当前仓库中该接口存在三个实现:
SearchService (abstract)
├── LuceneSearch (关键词全文检索,默认)
├── EmbeddingSearch (语义检索,基于 ONNX 句向量)
└── NoSearchService (空实现,禁用搜索时启用)
装配逻辑位于 ZeppelinServer.java:根据 zeppelin.search.enable 与 zeppelin.search.semantic.enable 两个配置项选择具体实现。三个实现的切换矩阵为:
search.enable |
search.semantic.enable |
生效实现 |
|---|---|---|
| true | false(默认) | LuceneSearch(既有行为) |
| true | true | EmbeddingSearch(语义检索) |
| false | 任意值 | NoSearchService |
其中 zeppelin.search.enable 默认即为 true;语义检索默认关闭,只需在 zeppelin-site.xml.template 中开启一个属性:
<property>
<name>zeppelin.search.semantic.enable</name>
<value>true</value>
</property>
六、LuceneSearch:默认关键词检索及其局限
LuceneSearch.java 是基于 Lucene StandardAnalyzer 的关键词全文检索,对应测试位于 LuceneSearchTest.java。它针对 Notebook 场景存在四个已知局限(这也是引入 EmbeddingSearch 的动机):
- 缺乏语义理解——"yesterday's spend" 无法命中
current_date - 1这类含义相同但措辞不同的 SQL; - SQL 分词不佳——
StandardAnalyzer会把表名analytics_db.daily_sales中的下划线与点号切碎; - 不索引输出——段落的执行结果(表格、文本输出)不可被检索;
- 仅精确匹配——用户必须猜测 Notebook 中使用的确切术语。
七、EmbeddingSearch:基于句向量的语义检索架构
EmbeddingSearch.java 是 Zeppelin 的语义搜索实现,完整设计方案见 docs/embedding-search.md。其核心链路为:
HuggingFace Tokenizer (DJL) → ONNX Runtime 推理(CPU)→ 内存索引(float[] + 元数据)
模型选型:all-MiniLM-L6-v2,输出 384 维句向量;ONNX 模型约 86MB(另有 22MB 量化版),在 CPU 上通过 ONNX Runtime 推理,单段落约 5ms。模型在首次使用时下载到 zeppelin.search.index.path/models/ 目录。
索引结构:内存中以 ConcurrentHashMap<String, IndexEntry> 存储,配合 ReadWriteLock 控制并发;每条 IndexEntry 保存 384 维 embedding、Notebook 名、段落文本、标题、提取出的 SQL 表名与段落输出。规模上,1 万段落约占用 15MB 内存、5 万段落约 75MB。索引会以带版本号的二进制文件 embedding_index.bin(当前为 v3)持久化到磁盘。
与 LuceneSearch 的索引内容对比:
| 内容 | LuceneSearch | EmbeddingSearch |
|---|---|---|
| 段落文本 | ✓ | ✓ |
| 段落标题 | ✓ | ✓ |
| Notebook 名 | ✓ | ✓(嵌入上下文) |
| 段落输出(TABLE, TEXT) | ✗ | ✓ |
| SQL 表名(FROM/JOIN) | ✗ | ✓(提取并加权) |
| Interpreter 前缀剥离 | ✗ | ✓ |
两阶段检索(这是 EmbeddingSearch 最具特色的机制):
- 阶段一(表发现):对查询做余弦相似度计算,从 top-20 结果中按排名加权收集 SQL 表名;
- 阶段二(表加权):对结果重新打分,每命中一个发现的表给段落加 0.05 分。
这使得 "click funnel analysis" 这类查询能够召回所有查询过同一批表的段落——即使它们的 SQL 文本差异很大。
三个关键设计决策(均来自 docs/embedding-search.md):
- 为什么用 ONNX Runtime 而非 Java ML 库:ONNX Runtime 支持与 Python 生态(HuggingFace、ChromaDB 等)完全相同的模型文件,保证 embedding 兼容性;
- 为什么用暴力余弦而非 HNSW/ANN:Zeppelin 的典型规模小于 5 万段落,暴力检索在归一化向量上耗时 <50ms,精确无近似误差且零额外复杂度;
- 为什么首次使用时下载模型而非打包:模型 86MB,打包会撑大发行包;按需下载保持发行包精简,且允许用户自行替换模型。
八、模型安装与完整性校验
从源码注释与 bin/install-search-model.sh 可见,模型并非随发行包内置,需要先执行安装脚本:
# 安装到默认路径(/tmp/zeppelin-index,与 zeppelin.search.index.path 默认值一致)
bin/install-search-model.sh
# 或指定索引目录
bin/install-search-model.sh /path/to/index
脚本固定模型为 all-MiniLM-L6-v2(固定 revision c9745ed1...),从 HuggingFace 下载 model.onnx 与 tokenizer.json 到 ${INDEX_PATH}/models/all-MiniLM-L6-v2/,并对两个文件做 SHA256 完整性校验(校验和硬编码在脚本内),下载过程使用 curl -fSL --connect-timeout 30 --max-time 300。若已有文件但校验失败,脚本会删除并重新下载。
对应地,EmbeddingSearch.java 在初始化时:
- 检查
model.onnx与tokenizer.json是否存在,缺失时抛出异常并提示先执行bin/install-search-model.sh; - 加载后用
verifyModelSha256再次校验模型哈希(SHA-256不可用时降级为警告而非失败); - 额外警告
zeppelin.search.index.path位于/tmp下的情况(进程重启后索引可能丢失)。
九、搜索结果前后端契约
无论使用哪种 SearchService 实现,服务端均返回 List<Map<String, String>>,字段语义如下(对比自 docs/embedding-search.md 的契约表):
| 字段 | LuceneSearch | EmbeddingSearch |
|---|---|---|
id |
noteId 或 noteId/paragraph/paragraphId |
相同 |
name |
Notebook 标题 | Notebook 标题 |
snippet |
高亮段落文本(<B> 标签) |
段落文本(不高亮) |
text |
完整段落文本 | 完整段落文本 |
header |
高亮段落标题(<B> 标签) |
段落标题(纯文本) |
title |
同 header |
段落标题(纯文本) |
tables |
空字符串 | 空格分隔的 SQL 表名 |
output |
空字符串 | 段落输出(截断至 300 字符) |
其中 title、tables、output 是专门的结构化字段;header 保留向后兼容性——LuceneSearch 下为高亮标题,EmbeddingSearch 下为纯文本标题。前端(Angular 与 Classic UI)按此契约渲染:代码块(按语言高亮)、输出块(来自 output 字段)、表名展示(来自 tables 字段)与语言徽标(sql、python、md 等)。
十、测试验证
语义搜索的正确性由 EmbeddingSearchTest.java 覆盖,运行方式(首次需下载模型约 86MB):
ZEPPELIN_EMBEDDING_TEST=true mvn test -pl zeppelin-server -Dtest=EmbeddingSearchTest
# 回归验证既有 Lucene 检索
mvn test -pl zeppelin-server -Dtest=LuceneSearchTest
其中两个关键测试用例值得关注:
semanticSearchFindsRelatedConcepts——验证 "yesterday's spending" 能将 SQL 支出查询排在无关的用户数查询之前,即语义相关性确实生效;newParagraphIsLiveIndexed——验证新增段落无需重启即可被实时检索(live indexing)。
十一、演进方向与适用前提
从 docs/embedding-search.md 的 "Future Work" 清单可以看到该功能后续规划:量化模型支持(22MB INT8 vs 86MB FP32)、嵌入相似度与关键词匹配结合的混合检索、面向离线环境的可配置模型 URL、初始索引重建时的批量嵌入、以及搜索结果中的相似度分数展示。
适用前提与限制(基于当前仓库现状):语义搜索依赖首次模型下载与 zeppelin.search.index.path 的持久化目录;默认索引路径 /tmp/zeppelin-index 重启即失,建议在生产环境显式配置为持久目录。同时,当前仓库的 EmbeddingSearch 依赖 onnxruntime 与 djl-tokenizers(版本分别见 zeppelin-server/pom.xml 的依赖声明),安装、构建与运行语义搜索时需要确保这些依赖可用。
综上,Zeppelin 的搜索体系呈现清晰的层次:文档站靠 Jekyll + Lunr.js 实现零依赖的静态检索,Notebook 靠 SearchService 抽象实现从关键词到语义的平滑演进,两者均由配置驱动、有测试护航,可在各自场景中直接落地使用。