从零开始,用 Lucene 搭建你的第一个搜索引擎

孙博 技术分享
搜索引擎 Lucene

你有没有想过,当你在搜索框里敲下几个字,背后到底发生了什么?

你可能第一时间会想到 Elasticsearch,或者 Solr —— 这两个耳熟能详的搜索中间件,几乎是"搜索引擎"的代名词。但你有没有注意到,它们的文档里都频繁出现同一个名字:Apache Lucene

没错,ES 和 Solr 的底层,都站着同一个引擎。Lucene 几乎统治了整个 Java 搜索生态,只是大多数时候它藏在幕后,被上层框架的光芒遮住了。

很多人听过 Lucene,但真正动手写过的人不多。原因无非是觉得它"底层"、"复杂"、"离业务太远"。但事实恰恰相反:Lucene 的 API 经过这些年的演进,已经相当友好。你只需要理解三个核心概念,就能跑通一个完整的搜索引擎。

今天这篇文章,不讲分布式,不讲集群,就用一个最简单的单机示例,带你走完搜索引擎从零到一的全过程。


先搞清楚:什么是索引?

在动手写代码之前,我们需要先理解一个核心概念:索引

如果你对搜索技术没有了解,可以先想一个生活场景:你手里有一本 500 页的书,想找"递归"这个概念出现在哪些页面。最笨的办法是从第 1 页开始逐页翻——这相当于数据库里的全表扫描。如果这本书有 5 亿页呢?

聪明的做法是翻到书末尾的"索引页",上面写着:递归 — 第 23 页、第 156 页、第 302 页。直接定位,不需要逐页扫描。

搜索引擎的"索引",就是这样一个东西。

正排索引:从文档到内容

所谓正排索引(Forward Index),就是"文档 → 内容"的映射。给定一个文档 ID,你能查到它的全部内容。

文档 1 → "杭州西湖,位于浙江省杭州市,是中国著名的风景名胜区..."
文档 2 → "北京故宫,位于北京市中心,是明清两代的皇家宫殿..."
文档 3 → "西湖十景包括苏堤春晓、曲院风荷、平湖秋月..."

这就像你知道了"第 23 页写了什么"。正排索引是存储的基础形式,数据库本质上也是一种正排结构。

正排索引的困境:为什么需要倒排?

有了正排索引,是不是就能搜索了?理论上可以,但实际用起来会非常痛苦。

假设你有 1000 万篇景区介绍,每篇描述平均 500 字。用户搜索"西湖",你需要逐个遍历这 1000 万篇文档,检查每篇的 500 字里是否包含"西湖"这两个字。这就是全表扫描,性能灾难。

你可能会说:MySQL 也有索引啊,为什么不用?

MySQL 的 B+ 树索引确实快,但它有一个致命限制:只能做前缀匹配WHERE name LIKE '西湖%' 可以走索引,但 WHERE name LIKE '%西湖%' 不行——因为"西湖"可能出现在字符串的任何位置,B+ 树无法定位。

对于 WHERE description LIKE '%西湖%' 这种场景,MySQL 只能放弃索引,全表扫描。1000 万条记录,每条 500 字,每次搜索都要扫描 50 亿个字符。这在生产环境是完全无法接受的。

根本原因在于:正排索引是为"已知文档 ID,查内容"设计的,不是为"已知关键词,查文档"设计的。

倒排索引:从词语到文档

为了解决这个问题,我们需要换一种思路:不存文档的完整内容,而是提取内容中的关键词,建立"关键词 → 文档列表"的映射。

这就是倒排索引(Inverted Index)——把正排关系反过来。

杭州 → [文档 1, 文档 3]
西湖 → [文档 1, 文档 3]
北京 → [文档 2]
故宫 → [文档 2]
苏堤 → [文档 3]

现在用户搜索"西湖",直接查倒排索引,O(1) 时间复杂度拿到文档 1 和文档 3 的列表。不需要扫描任何文档内容。

倒排索引是搜索引擎的核心数据结构。 当你搜索"西湖"时,Lucene 不是去逐个扫描每篇文档的内容,而是直接查倒排索引,瞬间拿到所有包含"西湖"的文档列表。这就是为什么搜索引擎能在毫秒级别从数十亿文档中返回结果。

为什么不能逐字建索引?

你可能会想:既然倒排索引这么好用,那我把每个字都建一个索引不就行了?"杭州西湖"拆成"杭"、"州"、"西"、"湖"四个字,每个字都建倒排索引。

这样做确实能搜索,但有两个严重问题。

第一,索引膨胀。 每个字都是一个词条,索引量会爆炸。1000 万篇文档,每篇 500 字,就是 50 亿个字的索引。存储和内存都扛不住。

第二,没有语义。 "杭"和"州"单独出现时,没有任何意义。用户搜索"杭州",你不可能把"杭"和"州"分开查,再取交集——这样会引入大量噪音("杭"可能出现在"杭帮菜"里,"州"可能出现在"广州"里)。

解决方案是分词:按语义切分,而不是按字切分。

分词:提取有意义的信息单元

分词就是把一段文本拆分成有意义的词语序列。

英文天然以空格分词,"hello world" 拆成 "hello" 和 "world" 没有任何歧义。但中文不行,我们需要专门的分词器来判断"杭州西湖"应该怎么拆。

常见的中文分词方案有三种:

基于词典的分词是最经典的方法。维护一个中文词典,按最长匹配或最大概率切分。"杭州西湖"在词典里有"杭州"和"西湖"两个词,就拆成这两个。代表算法有正向最大匹配、逆向最大匹配、双向最大匹配。IK Analyzer、Jieba 分词都属于这一类。

基于统计的分词用机器学习模型(HMM、CRF)来判断切分点。它能处理词典里没有的新词,比如"网红打卡地"这种网络热词。但需要训练数据,计算量更大。

混合分词结合两者:先用词典做基础切分,再用统计模型识别新词。这是目前主流分词器的做法。

分词之后,每个词都会建倒排索引。这样当用户搜索"西湖"时,Lucene 能找到所有包含"西湖"这个词的文档,而不是包含"西"或"湖"的文档。

词权重:不是所有词都同等重要

倒排索引解决了"能不能搜"的问题,但还有一个问题:"搜出来的结果怎么排序?"

用户搜索"杭州西湖",返回了 100 个结果。有的文档标题就是"杭州西湖",有的只是在描述里顺带提了一句"西湖"。显然,前者应该排在前面。

这就需要词权重的概念。

最经典的权重算法是 TF-IDF(Term Frequency - Inverse Document Frequency):

TF(词频):一个词在当前文档中出现的频率。"西湖"在文档 A 的标题里出现了 3 次,在文档 B 的描述里出现了 1 次,那文档 A 的 TF 更高,相关性更强。

IDF(逆文档频率):一个词在整个语料中出现的频率。"的"、"是"、"在"这些词几乎每篇文档都有,IDF 很低,区分度差;"西湖"只在少数文档中出现,IDF 很高,区分度强。

TF-IDF 的核心思想是:一个词在当前文档中出现越多,且在整个语料中出现越少,那它的权重就越高。

Lucene 9.x 默认使用 BM25 算法,这是 TF-IDF 的改进版,解决了 TF 过高时的饱和问题("西湖"出现 10 次和出现 100 次,权重差距不会无限放大)。

词权重让搜索引擎不仅能"找到"相关文档,还能"排序"相关文档,把最匹配的结果排在前面。这是搜索引擎区别于数据库查询的关键能力。


从原理到实践:Lucene 的三个核心概念

讲到这里,你应该已经理解了搜索引擎的基本原理:把文本分词,建立倒排索引,然后在索引上高效查询和排序。

那具体怎么实现呢?Lucene 把整个流程抽象成了三个核心步骤,对应三个核心概念。理解了它们,你就掌握了 Lucene 的全部。

想象一下你要搭建一个搜索服务,你需要做三件事:

第一件事:定义你的数据长什么样。 你要告诉 Lucene,"我有一条景区数据,它包含名称、描述、城市、评分这些字段"。在 Lucene 里,这叫做 Document(文档)——它不是指 Word 文档,而是指你要搜索的一条数据记录。

第二件事:把数据变成可搜索的索引。 有了数据定义,你需要把数据灌进去,让 Lucene 帮你分词、建倒排索引。这个过程叫做创建 Index(索引)

第三件事:在索引里搜索。 索引建好后,用户输入关键词,Lucene 帮你查倒排索引、计算相关性、返回排序后的结果。这个过程叫做 Search(搜索)

整个流程就是:数据 → Document → 写入 Index → 在 Index 中 Search

是不是听起来很简单?接下来我们一步步看代码怎么实现。


字段类型:Lucene 的第一道选择题

在动手选字段类型之前,我们需要先理解 Lucene 中两个完全独立的概念:索引存储

索引(Index):用于搜索。建立了索引,Lucene 才能根据你的查询条件快速找到对应的文档。没有索引,这个字段就无法被搜索到。

存储(Store):用于返回。保存了原始值,你在搜索结果中才能拿到这个字段的具体内容。没有存储,你只能知道"哪个文档匹配了",但拿不到这个字段的原始值。

这两个概念的组合,产生了三种典型场景:

  • 既能搜到,又能拿到值(有索引 + 有存储):标题、描述 —— 需要搜索,也要在结果中展示

  • 能搜到,但拿不到值(有索引 + 无存储):内部标签、分类 ID —— 只用于过滤条件,不需要展示

  • 拿得到值,但搜不到(无索引 + 有存储):图片 URL、开放时间 —— 只需要展示,不需要被搜索

理解了这两个维度,再来看 Lucene 的字段类型就清晰了。每个字段都需要回答两个问题:要不要建索引?要不要存储原始值?这两个选择的不同组合,对应 Lucene 提供的三种字段类型:

TextField 用于需要全文检索的文本字段。它会用分词器把文本拆成词语并建立倒排索引,搭配 Field.Store.YES 可同时存储原始值(用于搜索结果中返回原文)。景区名称、描述、地址这些字段都属于这一类。当你搜索"西湖"时,TextField 能帮你找到名称里包含"西湖"的文档。

StringField 用于精确匹配的字段。不分词,整个值作为一个 token 建索引。城市名、省份名、ID 这些字段适合用 StringField——你不会想把"杭州"拆成"杭"和"州"再来搜索。

StoredField 只存储,不建索引。用于那些只需要在搜索结果中展示、但不需要被搜索到的字段,比如图片 URL、开放时间。

数值字段比较特殊。Lucene 9.x 用 DoublePointIntPoint 这类 Point 字段来存储数值(支持范围查询和排序),如果需要把值返回给前端,还得额外加一个 StoredField 存原始值。这是因为 Point 字段内部用的是特殊的 BKD 树结构,存储格式和普通字段不同。

来看一个具体的例子。假设我们有一个景区数据模型:

public class ScenicSpot {
    private Long id;
    private String name;
    private String description;
    private String city;
    private Double rating;
    private String imageUrl;
    // getters and setters...
}

把它转成 Lucene Document:

public static Document toDocument(ScenicSpot spot) {
    Document doc = new Document();

    // 全文检索字段:分词 + 索引 + 存储
    doc.add(new TextField("name", spot.getName(), Field.Store.YES));
    doc.add(new TextField("description", spot.getDescription(), Field.Store.YES));

    // 精确匹配字段:不分词 + 索引 + 存储
    doc.add(new StringField("id", String.valueOf(spot.getId()), Field.Store.YES));
    doc.add(new StringField("city", spot.getCity(), Field.Store.YES));

    // 数值字段:支持范围查询和排序
    doc.add(new DoublePoint("rating", spot.getRating()));
    doc.add(new StoredField("rating_display", spot.getRating()));

    // 仅展示字段:只存储,不索引
    doc.add(new StoredField("imageUrl", spot.getImageUrl()));

    return doc;
}

这段代码看起来简单,但它体现了 Lucene 最核心的设计思想:每个字段都有明确的搜索意图,不是所有字段都需要被搜索。 合理的字段类型配置,直接影响索引大小、搜索性能和搜索质量。


中文分词:绕不开的话题

前面讲倒排索引时提到,中文分词是建索引的前提。Lucene 自带的分词器对中文支持很弱,所以中文搜索场景几乎都会替换为第三方分词器。

IK Analyzer 的两种模式

最常用的是 IK Analyzer,它提供两种模式:smart=truesmart=false。这两个参数的差异,直接决定了你的搜索体验。

智能模式smart=true)追求的是"精准切分"。它会尽量按语义级别切分,输出最少的、最有意义的词语。

举个例子,对于"中华人民共和国"这个字符串:

智能模式输出:中华人民共和国

它认为这是一个完整的专有名词,不需要拆开。再比如"杭州西湖风景名胜区":

智能模式输出:杭州 / 西湖 / 风景 / 名胜区 /

智能模式会把文本切成几个独立的意义单元。注意,"风景名胜区"并不会被当作一个整体保留——IK 的智能模式倾向于拆成更小的语义单元。

细粒度模式smart=false)追求的是"最大召回"。它会尽可能多地切分出词语,包括各种粒度的组合。

同样的"中华人民共和国":

细粒度模式输出:中华人民共和国 / 中华人民 / 中华 / 华人 / 人民共和国 / 人民 / 共和国 / 共和 /

"杭州西湖风景名胜区":

细粒度模式输出:杭州 / 西湖 / 风景 / 风景名胜区 / 风景名 / 名胜区 / 名胜 /

你会发现,细粒度模式把能拆的都拆了,连"共和国"、"共和"、"国"这种组合都输出了。

两种模式怎么用?

理解了差异,使用场景就很清晰了。

索引端用细粒度模式。 建索引时,你希望倒排索引里尽可能多地存储词语。这样无论用户搜"杭州"还是搜"西湖",还是搜"风景名胜区",都能命中这篇文档。细粒度模式提高了召回率——能被搜到的可能性更大。

搜索端用智能模式。 用户输入关键词时,你希望切分出的词语尽可能精准、有意义。用户搜"杭州西湖",切成"杭州"和"西湖"两个词去查倒排索引就够了。如果切成"杭"、"州"、"西"、"湖",反而会引入大量噪音。

// 索引端:细粒度分词,最大化召回
Analyzer indexAnalyzer = new IKAnalyzer(false);

// 搜索端:智能分词,精准匹配
Analyzer searchAnalyzer = new IKAnalyzer(true);

这种"索引细、搜索粗"的策略,是中文搜索的经典实践。

中文分词器会处理英文吗?

这是一个很多人会问的问题:如果我的数据里既有中文又有英文,IK Analyzer 能同时处理吗?

答案是:能,而且处理得还不错。

IK Analyzer 内部有语言检测逻辑。遇到中文字符时,它按中文分词规则切分;遇到英文字母时,它按空格和标点切分(和 Lucene 默认的 StandardAnalyzer 行为一致)。

举个例子:

输入:"杭州西湖是5A级景区,WiFi覆盖全区域"

IK Analyzer 输出:杭州 / 西湖 / / 5a / / 景区 / wifi / 覆盖 / / 区域

中文部分"杭州西湖"被正确分词,英文部分"WiFi"被整体保留并转为小写"wifi",数字"5A"也被转为小写"5a"。

所以如果你的数据主要是中文,夹杂一些英文(比如产品型号、技术术语),IK Analyzer 单独就能搞定,不需要额外配置。

多语言场景怎么办?

如果你的数据包含大量非中英文内容——比如日文、韩文、阿拉伯文、泰文——情况就复杂了。

每种语言的分词规则完全不同:日文需要按"助词/动词/名词"切分,韩文需要按"音节"切分,阿拉伯文需要从右向左处理。一个分词器不可能通吃所有语言。

这时候有两个选择:

方案一:使用多语言分词器。 Lucene 的 lucene-analyzers-common 模块提供了多种语言的分词器,比如 JapaneseAnalyzerKoreanAnalyzer(基于 Nori 分词)。你可以为不同语言的字段配置不同的分词器。

// 中文字段用 IK
doc.add(new TextField("name_cn", spot.getNameCn(), Field.Store.YES));
// 配置时用 IKAnalyzer

// 日文字段用 Nori
doc.add(new TextField("name_ja", spot.getNameJa(), Field.Store.YES));
// 配置时用 JapaneseAnalyzer

方案二:使用 PerFieldAnalyzerWrapper。 这是 Lucene 提供的一个工具类,允许你为不同字段指定不同的分词器。

Map<String, Analyzer> analyzerPerField = new HashMap<>();
analyzerPerField.put("name_cn", new IKAnalyzer(true));    // 中文用 IK
analyzerPerField.put("name_ja", new JapaneseAnalyzer());   // 日文用 Nori
analyzerPerField.put("name_kr", new KoreanAnalyzer());     // 韩文用 Nori

PerFieldAnalyzerWrapper wrapper = new PerFieldAnalyzerWrapper(
    new StandardAnalyzer(),  // 默认分词器
    analyzerPerField
);

这样,不同字段的文本会用对应的分词器处理,互不干扰。

如果不确定字段里有什么语言?

现实业务中,你可能有一个"描述"字段,用户可能输入中文、英文、甚至混合内容。这时候你无法提前确定用哪个分词器。

这种情况下,推荐的做法是:

以中文分词器为主。 因为中文分词器(如 IK)本身就能处理英文,而英文分词器处理不了中文(会把整段中文当成一个 token)。所以用中文分词器做兜底,至少能保证中英文都能正常切分。

对于其他语言(日韩阿泰等),如果确实有需求,可以:

  1. 在数据入库时做语言检测,路由到对应的索引或字段

  2. 使用 Elasticsearch 的 multilingual 分析器(底层是 ICU 分词)

  3. 或者干脆为每种语言建独立字段,前端根据用户语言偏好查询对应字段

中文分词的更好建议

如果你的项目主要处理中文,这里有几条实战建议:

第一,优先用 IK Analyzer 或 HanLP。 IK 轻量、稳定,适合大多数场景。HanLP 功能更丰富,支持词性标注、命名实体识别,适合需要更精细控制的场景。

第二,维护自定义词典。 通用分词器不认识你业务里的专有名词。比如"灵隐寺"可能在默认词典里,但"西溪湿地"不一定有。IK 支持加载自定义词典,把你的业务术语加进去,分词准确率会显著提升。

# ext.dic(自定义词典)
西溪湿地
灵隐寺
千岛湖
宋城千古情

第三,索引和搜索用不同的分词策略。 前面提到的"索引细粒度、搜索智能模式"是基本原则。如果你的场景对召回率要求更高(比如客服知识库),索引端可以进一步开启同义词扩展。

第四,注意停用词。 "的"、"是"、"在"这些词几乎没有搜索价值,应该在分词阶段过滤掉。IK 支持配置停用词词典,避免这些高频无意义词污染倒排索引。


创建索引:把数据灌进去

有了 Document 和 Analyzer,创建索引就是一行代码的事。

public void createIndex(List<ScenicSpot> spots) throws IOException {
    // 打开索引目录(文件系统路径)
    Directory directory = FSDirectory.open(Paths.get("/data/lucene-index"));

    // 配置写入参数
    IndexWriterConfig config = new IndexWriterConfig(new IKAnalyzer(false));
    config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
    config.setRAMBufferSizeMB(256.0);  // 内存缓冲区大小

    try (IndexWriter writer = new IndexWriter(directory, config)) {
        for (ScenicSpot spot : spots) {
            writer.addDocument(toDocument(spot));
        }
        writer.commit();
    }
}

这里有几个值得注意的地方。

Directory 是 Lucene 对索引存储位置的抽象。FSDirectory 表示存到文件系统,这也是生产环境的标准做法。测试时可以用 ByteBuffersDirectory(9.x 中替代了老的 RAMDirectory)做内存索引,避免磁盘 IO。

IndexWriterConfig.OpenMode.CREATE 表示每次创建都清空重建。如果想在已有索引上追加数据,用 CREATE_OR_APPEND

RAMBufferSizeMB 控制写入缓冲区大小。默认是 16MB,数据量大时可以调高到 256MB 甚至更大,减少刷盘次数,显著提升索引速度。

IndexWriter 是线程安全的,可以在多个线程中并发写入。但要注意,每次 commit() 都会触发一次索引刷新,频繁 commit 会影响性能。批量写入时,建议写完所有文档再统一 commit。


搜索查询:从关键词到结果

索引建好了,搜索就很简单了。

public List<ScenicSpot> search(String keyword) throws Exception {
    Directory directory = FSDirectory.open(Paths.get("/data/lucene-index"));
    IndexReader reader = DirectoryReader.open(directory);
    IndexSearcher searcher = new IndexSearcher(reader);

    // 在多个字段中搜索
    String[] fields = {"name", "description", "city"};
    QueryParser parser = new MultiFieldQueryParser(fields, new IKAnalyzer(true));
    Query query = parser.parse(keyword);

    // 执行搜索,返回前 100 条
    TopDocs topDocs = searcher.search(query, 100);

    // 提取结果
    List<ScenicSpot> results = new ArrayList<>();
    for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
        Document doc = searcher.doc(scoreDoc.doc);
        results.add(fromDocument(doc, scoreDoc.score));
    }

    reader.close();
    return results;
}

MultiFieldQueryParser 会在你指定的多个字段中同时搜索,用 OR 逻辑组合结果。搜"西湖"时,名称里包含"西湖"的文档和描述里包含"西湖"的文档都会被返回,但名称命中的相关性评分会更高——因为字段越短、命中越集中,评分越高。

说到评分,Lucene 默认使用 BM25 算法(9.x 的默认值,早期版本用 TF-IDF)。BM25 的核心思想是:一个词在当前文档中出现越多(词频高),且在整个索引中出现越少(文档频率低),那它的区分度就越高,评分权重就越大。 这就是为什么搜"西湖"时,名称里满是"西湖"的景区会排在描述里顺带提了一句"西湖"的景区前面。

如果你需要自定义排序,比如先按相关性、再按评分值降序:

Sort sort = new Sort(
    SortField.FIELD_SCORE,
    new SortField("rating", SortField.Type.DOUBLE, true)
);
TopDocs topDocs = searcher.search(query, 100, sort);

查询方式不止关键词

QueryParser 解析的其实是 Lucene 的查询语法。除了直接输入关键词,它还支持很多高级语法:

指定字段搜索: name:西湖 只在 name 字段中搜索"西湖"。

布尔运算: name:西湖 AND city:杭州 同时满足两个条件。name:西湖 OR name:千岛湖 满足任一即可。city:杭州 AND NOT name:西湖 在杭州但排除西湖。

通配符: name:西* 匹配所有以"西"开头的名称。

模糊搜索: name:西湖~ 允许一定的编辑距离,能匹配到"西胡"之类的拼写错误。

所谓编辑距离(Levenshtein Distance),就是把一个词转换成另一个词,最少需要多少次单字符操作(插入、删除、替换)。举个例子:

  • "西湖" → "西胡":把"湖"替换成"胡",编辑距离为 1

  • "西湖" → "西安":把"湖"替换成"安",编辑距离也是 1

你会发现一个问题:"西胡"和"西湖"语义相近,匹配合理;但"西安"和"西湖"语义完全无关,编辑距离却同样是 1。这就是编辑距离的本质局限——它只看字符差异,不看语义。

Lucene 默认允许最大编辑距离为 2。这意味着对于短词(比如两个字的词),几乎所有"长得像"的词都会被匹配到,不管语义是否相关。所以模糊搜索更适合用于较长的词或短语,或者配合其他策略(比如限制最小词长)来降低误匹配。

短语搜索: description:"断桥残雪" 精确匹配短语,而不是拆开后各自匹配。

如果代码中需要构建更复杂的查询逻辑,可以用 BooleanQuery 编程式组合:

BooleanQuery.Builder builder = new BooleanQuery.Builder();
builder.add(new TermQuery(new Term("city", "杭州")), BooleanClause.Occur.MUST);
builder.add(DoublePoint.newRangeQuery("rating", 4.0, 5.0), BooleanClause.Occur.MUST);
BooleanQuery query = builder.build();

这个查询的含义是:城市必须是"杭州",且评分在 4.0 到 5.0 之间。


一个容易忽略的点:IndexReader 的快照语义

DirectoryReader.open() 打开的是索引的一个快照。如果你在搜索过程中用 IndexWriter 写入了新数据,已经打开的 reader 是看不到这些新数据的。

这意味着如果你用一个全局的 reader 实例来服务所有搜索请求,新增的数据永远不会被搜到。

解决方案很简单:每次搜索前重新打开 reader。

// 每次搜索都打开新的 reader
public List<ScenicSpot> search(String keyword) throws Exception {
    try (IndexReader reader = DirectoryReader.open(directory)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        // ...
    }
}

在生产环境中,频繁打开关闭 reader 有性能开销。更优雅的做法是用 SearcherManager 来管理——它会缓存 reader 实例,并在索引更新后自动刷新。但对于入门来说,每次新建 reader 就足够了。


写在最后

回到开头说的三个核心概念:Document 定义数据结构,Index 存储倒排索引,Search 执行查询排序。

Lucene 的 API 看起来有不少类,但本质上就是这三件事。Elasticsearch 是 Lucene 的分布式封装,Solr 也是。它们的核心逻辑完全一样——只不过帮你处理了分片、副本、REST API、集群管理这些工程化的事情。

理解了 Lucene 的底层原理,再去用 Elasticsearch,你会清楚地知道它在帮你做什么,以及为什么要这样配置。这比"照着文档配参数"要靠谱得多。

不过,需要说明的是:本文的示例只是搜索引擎最基础的入门。 它展示了如何直接使用 Lucene(而不是基于 Elasticsearch 等上层工具)完成最基本的索引构建和搜索查询,帮助你初步理解搜索引擎的底层原理。

真实的搜索系统远比这复杂得多。

当用户在搜索框里输入"杭州附近适合带小孩玩的地方"时,这句话是口语化的、不完整的,甚至可能有错别字。你不能直接拿它去查倒排索引——你得先理解用户到底在搜什么。

这背后是一整条搜索链路:首先对用户输入进行分词和词性标注,识别出"杭州"(地名)、"附近"(方位词)、"适合"(动词)、"带小孩"(意图标签)、"玩"(行为动词);然后进行纠错("西湖"打成"西胡"?)、关键词改写("带小孩玩的地方" → "亲子游景点");接着对每个词元做实体识别和注入,将"杭州"映射为城市实体 ID,将"亲子游景点"关联到具体的品类标签。

拿到结构化查询后,系统可能要从多个独立的数据源中检索——商品索引、内容索引、POI 索引,每一个可能都是独立的 Lucene 服务。各路结果汇总后,进入粗排(快速过滤掉明显不相关的结果)、精排(用更复杂的模型对每条结果打分)、重排(考虑多样性、时效性、商业权重等业务因素),最终将最符合用户期待的 Top N 条结果返回。

从用户敲下几个字,到屏幕上出现搜索结果,中间经历的远不止"查一下倒排索引"这么简单。但所有这些复杂流程的基石,就是今天我们用 Lucene 跑通的这三步:Document → Index → Search。

万丈高楼平地起。理解了地基,才能看懂高楼。