使用 Lucene 搜索你的 Bean —— Elasticsearch

作者:来自 Elastic David Pilato

我们完成了 Bean 的映射,拥有一个 writer,输入了 Bob,筛选了 Club,统计了 facets,提供了前缀建议,并绘制了搜索结果。这就是 JVM 旁边的 Lucene。

使用相同的 Track Bean。使用相同的验收目标。当倒排索引不再位于 IndexWriter 后面,而是位于一个集群 URL 后面时,会发生什么变化?

将 Elasticsearch 添加到 Maven

Lucene 是一次添加一个 artifact(lucene-core,然后是 analysis-common、suggest、facet、highlighter)逐步构建起来的。Elasticsearch 则只有一个 Java 客户端坐标。实现时请查阅当前的稳定版本;本系列使用 9.5.4。

xml 复制代码
`

1.  <dependency>
2.    <groupId>co.elastic.clients</groupId>
3.    <artifactId>elasticsearch-java</artifactId>
4.    <version>9.5.4</version>
5.  </dependency>

`AI写代码

没有 Directory。没有 IndexWriter。也不需要在 Java 中手动构建 Analyzer 图。

连接集群

让客户端指向一个 URL 并进行身份验证 ------ 生产环境使用 API key:

less 复制代码
`

1.  ElasticsearchClient client = ElasticsearchClient.of(b -> b
2.          .host(System.getenv("ES_URL"))          // e.g. https://es.example.com:9200
3.          .apiKey(System.getenv("ES_API_KEY")));  // the API Key

`AI写代码

只声明一次映射

在 Lucene 中,你需要逐个字段构建 Document:使用 TextField 进行搜索,使用 StringField 保存原始标签,使用另一个 StringField 保存经过大小写折叠处理的过滤字段,再加上一个保留原始大小写的 facet 字段。在 Elasticsearch 中,你只需要将这种结构一次性声明为 index template ------ analyzer、normalizer、properties ------ 然后重新创建索引:

less 复制代码
 `1.          // The index template name
2.          .name("tracks")
3.          // The index patterns this template applies
4.          .indexPatterns("tracks*")
5.          .template(te -> te
6.                  .settings(s -> s.analysis(a -> a
7.                          // The custom "track" analyzer
8.                          .analyzer("track", an -> an.custom(c -> c
9.                                  .tokenizer("standard")
10.                                  .filter("lowercase", "asciifolding")))
11.                          // The custom "keyword_ci" normalizer
12.                          .normalizer("keyword_ci", n -> n.custom(c -> c
13.                                  .filter("lowercase", "asciifolding")))))
14.                  .mappings(m -> m
15.                          .properties("title", textWithRaw())
16.                          .properties("artist", textWithRaw())
17.                          .properties("genre", textWithRaw())
18.                          // album, label, comment...
19.                          .properties("key", p -> p.keyword(k -> k
20.                                  .normalizer("keyword_ci")
21.                                  .fields("raw", f -> f.keyword(kw -> kw))))
22.                          .properties("bpm", p -> p.double_(d -> d))
23.                          .properties("rating", p -> p.integer(i -> i))
24.                          .properties("year", p -> p.integer(i -> i)))));

26.  if (client.indices().exists(e -> e.index("tracks")).value()) {
27.      // This is only if you need to start from scratch at every run.
28.      client.indices().delete(d -> d.index("tracks"));
29.  }
30.  // This can be omitted actually as the first sent document 
31.  // will create the index automatically.
32.  client.indices().create(c -> c.index("tracks"));`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

textWithRaw() 是在一个 helper 中完成 Mapping 的核心方法 ------ 三个作用:

rust 复制代码
`

1.  private static Property textWithRaw() {
2.      return Property.of(p -> p.text(t -> t
3.              .analyzer("track")
4.              .fields("raw", f -> f.keyword(k -> k))
5.              .fields("normalized", f -> f.keyword(k -> k.normalizer("keyword_ci")))));
6.  }

`AI写代码
作用 Lucene(你编写) Elasticsearch(你声明)
全文搜索 TextField("genre", ...) genre text,analyzer track
Facet / UI 标签 SortedSetDocValuesFacetField("genre") genre.raw keyword(不使用 normalizer)
过滤器 / 标签 StringField("genre.raw.normalized") genre.normalized + keyword_ci

这与 Facets 文章中的 Lucene 拆分方式相同:显示 和过滤 是两个字段。terms 聚合返回的是已索引 的 term,因此 facet 标签需要在 .raw 中保留原始大小写(Club)。过滤则通过带有 keyword_ci 的 .normalized 进行,因此 Club、club 和 CLUB 都能匹配相同的文档。

key 遵循相同的思路,用于 Camelot code:在经过规范化的父字段(4a)上进行过滤,在轮盘上通过 key.raw 显示 10A。修改 mapping 后重新创建索引 ------ normalizer 位于 mapping 中,而不是查询中。

注意,这段 Java 代码实际上可以直接替换为纯 JSON curl 请求:

arduino 复制代码
`

1.  curl -X PUT "http://localhost:9200/_index_template/tracks" \
2.    -H "Content-Type: application/json" \
3.    -d '<JSON MAPPING HERE>'

`AI写代码

以下 JSON 展示了 tracks 索引的完整 mapping(<JSON MAPPING HERE>)。

ruby 复制代码
`

1.  ```json
2.  {
3.    "index_patterns": [ "tracks*" ],
4.    "template": {
5.      "settings": {
6.        "analysis": {
7.          "analyzer": {
8.            "track": {
9.              "type": "custom",
10.              "tokenizer": "standard",
11.              "filter": [ "lowercase", "asciifolding" ]
12.            }
13.          },
14.          "normalizer": {
15.            "keyword_ci": {
16.              "type": "custom",
17.              "filter": [ "lowercase", "asciifolding" ]
18.            }
19.          }
20.        }
21.      },
22.      "mappings": {
23.        "properties": {
24.          "title": {
25.            "type": "text",
26.            "analyzer": "track",
27.            "fields": {
28.              "raw": {
29.                "type": "keyword"
30.              },
31.              "normalized": {
32.                "type": "keyword",
33.                "normalizer": "keyword_ci"
34.              }
35.            }
36.          },
37.          "artist": {
38.            "type": "text",
39.            "analyzer": "track",
40.            "fields": {
41.              "raw": {
42.                "type": "keyword"
43.              },
44.              "normalized": {
45.                "type": "keyword",
46.                "normalizer": "keyword_ci"
47.              }
48.            }
49.          },
50.          "genre": {
51.            "type": "text",
52.            "analyzer": "track",
53.            "fields": {
54.              "raw": {
55.                "type": "keyword"
56.              },
57.              "normalized": {
58.                "type": "keyword",
59.                "normalizer": "keyword_ci"
60.              }
61.            }
62.          },
63.          "key": {
64.            "type": "keyword",
65.            "normalizer": "keyword_ci",
66.            "fields": {
67.              "raw": {
68.                "type": "keyword"
69.              }
70.            }
71.          },
72.          "bpm": {
73.            "type": "double"
74.          },
75.          "rating": {
76.            "type": "integer"
77.          },
78.          "year": {
79.            "type": "integer"
80.          }
81.        }
82.      }
83.    }
84.  }
85.  ```

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)收起代码块![](https://csdnimg.cn/release/blogv2/dist/pc/img/arrowup-line-top-White.png)

假设你有一个 Elasticsearch 管理团队(类似 DBA),你可以把 JSON 交给他们,让他们管理 index template。这意味着那些"Java"调用其实毫无用处。

原样批量写入 Bean

不需要 TrackDocumentMapper.toDocument。Bean 就是 document:

less 复制代码
`

1.  try (BulkIngester<Void> ingester = BulkIngester.of(b -> b
2.          .client(client)
3.          .maxOperations(500)
4.          .globalSettings(s -> s.index("tracks")))) {
5.      for (Track track : tracks) {
6.          ingester.add(op -> op.index(idx -> idx.id(track.id()).document(track)));
7.      }
8.  }
9.  client.indices().refresh(r -> r.index("tracks"));

`AI写代码

我们每执行 500 个操作就刷新一次 bulk,并依靠 try-with-resources 代码块自动刷新剩余的操作并关闭 ingester。refresh 会让 bulk 对搜索可见 ------ 这与 Lucene 在新的 DirectoryReader 之前需要执行 commit 的时机相同。

一个小技巧:使用 .globalSettings(s -> s.index("tracks")) 可以避免在 bulk ingester 中为每个操作重复指定索引名称。这样可以节省网络带宽。

输入"Bob"

在 Lucene 中,你构建了一个 BooleanQuery:每个字段使用 BoostQuery,并在最后一个 token 上使用尾随的 PrefixQuery。Elasticsearch 则将这种结构表示为一个 multi_match,类型为 bool_prefix,并设置 operator: and:

less 复制代码
`

1.  Query bob = Query.of(qb -> qb.bool(b -> b
2.          .must(m -> m.multiMatch(mm -> mm
3.                  .query("Bob")
4.                  .type(TextQueryType.BoolPrefix)
5.                  .operator(Operator.And)
6.                  .fields("title^4", "artist^3", "genre^2",
7.                          "album^1.5", "label^1", "comment^0.5")))));

9.  SearchResponse<Track> response = client.search(s -> s
10.                  .index("tracks")
11.                  .query(bob),
12.          Track.class);

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

与之前相同的 boost 层级。规则也相同:多个 token 使用 AND 连接;只有最后一个 token 是前缀。搜索结果直接反序列化回 Track ------ 在这个演示中,不需要通过存储的 id 再执行一次关联。

查询 Lucene 行为 Elasticsearch
Bob 62 个结果 62 个结果
bob sincla bob + sincla... 前缀 bool_prefix + and
bo sinclar 空结果(单独的 bo 太弱) 空结果
ouse 找不到 House 相同 ------ 不是中间匹配

添加过滤器(包含 Club)

将全文搜索子句包装为 must,并在规范化 的对应字段上添加 filter ------ 进行约束,而不是评分:

less 复制代码
`

1.  Query bool = Query.of(qb -> qb.bool(b -> b
2.          .must(m -> m.multiMatch(mm -> mm
3.                  .query("Bob")
4.                  .type(TextQueryType.BoolPrefix)
5.                  .operator(Operator.And)
6.                  .fields("title^4", "artist^3", "genre^2",
7.                          "album^1.5", "label^1", "comment^0.5")))
8.          .filter(f -> f.term(t -> t
9.                  .field("genre.normalized")
10.                  .value("club")))));

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

Club 和 club 会匹配相同的文档,因为 keyword_ci 会在索引时对 .normalized 进行小写转换(并进行折叠)。不要对它使用 wildcard。除非你希望进行区分大小写的精确标签匹配,否则不要在 .raw 上进行过滤。

如果你还希望获得保留同级 genre 可见性的facet 直方图,那么这个 genre 筛选项将从 query 移到 post_filter ------ 下一节会介绍。

排除两个 key(4A 和 4B)

使用相同的 must + filter,再加上 must_not。同一维度上的多个 key 使用 OR (should、minimum_should_match = 1)。排除条件始终保留在 query 中(它们会缩小每个面板的结果范围):

less 复制代码
`

1.  Query bool = Query.of(qb -> qb.bool(b -> b
2.          .must(m -> m.multiMatch(mm -> mm
3.                  .query("Bob")
4.                  .type(TextQueryType.BoolPrefix)
5.                  .operator(Operator.And)
6.                  .fields("title^4", "artist^3", "genre^2",
7.                          "album^1.5", "label^1", "comment^0.5")))
8.          .filter(f -> f.term(t -> t.field("genre.normalized").value("club")))
9.          .mustNot(mn -> mn.bool(k -> k
10.                  .should(s -> s.term(t -> t.field("key").value("4a")))
11.                  .should(s -> s.term(t -> t.field("key").value("4b")))
12.                  .minimumShouldMatch("1")))));

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)
UI 结果 Elasticsearch 子句
输入"Bob" 62 个结果 must multi_match bool_prefix
包含 Club 26 个结果 genre 上的 filter / post_filter
排除 4A 或 4B 23 个结果 must_not(4a should 4b)

Facets:一次 _search,Query DSL 中的 Lucene DrillSideways

Lucene 需要 FacetsCollector、range readers,以及一个 DrillSideways 子类,以便在选中某个 chip 后,genre 和 key 仍然保持可见,同时 BPM / rating / year 的范围随之缩小。

Elasticsearch 则在一次 _search 中同时返回结果表和直方图:

  • query ------ 全文搜索、非 sideways 的包含条件(bpm、rating、year),以及所有排除条件;

  • post_filter ------ 仅包含 genre 和 key 的 includes (缩小搜索结果,但不会缩小聚合的基础数据集);

  • filter aggregations ------ 重新创建 sideways 映射:genre 根据 key 过滤(不根据 genre 过滤),key 根据 genre 过滤(不根据 key 过滤),bpm/rating/year 同时根据两者过滤。

Genre bucket 读取 genre.raw 。Key bucket 读取 key.raw。用于缩小搜索结果的 chip 使用规范化字段:

less 复制代码
`

1.  client.search(s -> {
2.              s.index("tracks")
3.                      .size(25)
4.                      .query(query)   // Bob + excludes; no genre/key includes
5.                      .aggregations("genre", a -> a
6.                              .filter(keyChip)      // omit genre
7.                              .aggregations("genre", m -> m.terms(t -> t
8.                                      .field("genre.raw").size(50))))
9.                      .aggregations("key", a -> a
10.                              .filter(genreChip)    // omit key
11.                              .aggregations("key", m -> m.terms(t -> t
12.                                      .field("key.raw").size(50))))
13.                      .aggregations("drill", a -> a
14.                              .filter(genreAndKey)
15.                              .aggregations("bpm", /* ranges */)
16.                              .aggregations("rating", /* terms */)
17.                              .aggregations("year", /* decade histogram */));
18.              s.postFilter(genreAndKey);   // hits only
19.              return s;
20.          },
21.          Track.class);

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

在 q=Bob 下:Club 为 26 个(不是 club),BPM 120--130 为 52 ,rating 5 为 13 ,按十年划分的结果符合预期------与 Lucene 具有相同的数字和相同的标签。点击 Club:BPM 范围会缩小;Dance 仍然保留在 genre 面板中。点击一个 Camelot 切片:key 轮盘会以相同的原因保留其同级选项。

作用 Lucene Elasticsearch
复选框标签 + 计数 SortedSetDocValuesFacetField → $facets 对 genre.raw / key.raw 使用 terms
Chip(sideways include) DrillDownQuery.add post_filter + filter aggs
Chip(非 sideways / out) 基础 BooleanQuery FILTER / MUST_NOT query filter / must_not
数值直方图 *RangeFacetCounts range / histogram aggs

size = 0 表示仅返回聚合结果(没有搜索结果页面,也没有 highlight )。该 session 仍然会报告 totalHits。

Suggest:搜索 + highlight,不需要第二个 Directory

Lucene 在自己的 Directory 上运行 AnalyzingInfixSuggester。这里的自动补全会复用 track 索引:对 title / artist / genre 使用 multi_match bool_prefix,请求 highlights,然后去重生成建议。空 scope 仍然不会返回任何结果。(与 Lucene 不同,非空 scope 不会用于限制 ES query ------ Demo 仍然会从 suggestion payload 中固定使用 chips。)

less 复制代码
`

1.  SearchResponse<Track> response = client.search(s -> s
2.                  .index("tracks")
3.                  .size(200)
4.                  .query(q -> q.multiMatch(mm -> mm
5.                          .query("club")
6.                          .type(TextQueryType.BoolPrefix)
7.                          .operator(Operator.And)
8.                          .fields("title", "artist", "genre")))
9.                  .highlight(h -> h.fields(
10.                          NamedValue.of("title", HighlightField.of(f -> f.numberOfFragments(0))),
11.                          NamedValue.of("artist", HighlightField.of(f -> f.numberOfFragments(0))),
12.                          NamedValue.of("genre", HighlightField.of(f -> f.numberOfFragments(0))))),
13.          Track.class);

`AI写代码![](https://csdnimg.cn/release/blogv2/dist/pc/img/runCode/icon-arrowwhite.png)

输入 club → 会返回一个 genre 结果,其中包含你可以显示为 Club House 的 markup。选择它仍然意味着:在 genre 上添加一个 FILTER chip,然后执行搜索 ------ 无需在每次发生变更后重新构建第二个 dictionary,同时保持相同的 UI contract。

用数字说话

相同的 TrackSearch contract,相同的测试,两个实现类。两边都已经超越了最初的草稿(session、完整的 facet 映射、搜索结果上的 highlights)。两者之间的差异仍然在于你需要负责什么 :Lucene 将 analyzer、Document、collectors、DrillSideways 和第二个用于 suggest 的 Directory 都内联到应用中;Elasticsearch 则声明一个 template 和一个 Query DSL body,然后对 buckets 和 highlights 进行组织。

性能 是另一回事,而且比"cluster = 更慢"要复杂得多。在相同的 4,322 首曲目上,本地测量结果如下:

步骤 Lucene(RAM) Elasticsearch(localhost:9200)
索引 / 重建所有曲目 523 ms 639 ms
MatchAll 搜索 ~5 ms ~16 ms
q=Bob 搜索 ~15--30 ms ~15--30 ms

索引开销很小 ------ 对完整曲库来说只多了一点 100 ms 左右,其中还包括网络往返和 bulk 路径。搜索方面,Lucene-in-RAM 在 MatchAll 上仍然更快(~5 ms 对比 ~16 ms):没有序列化,也没有 HTTP 。一旦查询包含实际工作(q=Bob),在这个数据集上两者都落在相同的 15--30 ms 区间。

因此,Elasticsearch 在这里并没有带来更快的微基准测试结果。但你真正获得的是运维能力:

  • 索引可以跨进程重启继续存在(除非你主动选择,否则无需启动时重新构建);

  • 副本可以在某个节点宕机时继续提供服务;

  • 扩展搜索和索引容量属于 cluster 层面的工作,而不是在应用中再增加一个 Directory;

  • 多个应用实例可以共享一个搜索层,而不是每个实例都持有一个可能逐渐产生偏差的 cache。

相关性也并不完全相同。 相同的验收计数(Bob → 62、Club → 26、......)并不意味着相同的 top-N 排序。输入 joe:两个搜索引擎都会返回相同的 11 个标题 ,但 Lucene 会将 Joe Smooth / Joe Killington 排得更高,而 Elasticsearch 更倾向于 Miss You (Joe Liggins) 或 Joey Negro。这不是过滤器 bug ------ 而是 query 结构造成的。

Lucene 实现会将 q=joe 打印为每个字段上的 term 加上 prefix(SHOULD 子句会累加;term leaf 使用 BM25):

less 复制代码
`

1.  ((title:joe)^4.0 (title:joe*)^1.0 (artist:joe)^3.0 (artist:joe*)^0.75
2.   (genre:joe)^2.0 (genre:joe*)^0.5 (album:joe)^1.5 (album:joe*)^0.375
3.   (label:joe)^1.0 (label:joe*)^0.25 (comment:joe)^0.5 (comment:joe*)^0.125)~1

`AI写代码

Elasticsearch 的 multi_match bool_prefix 在只有一个 token 时,并不是这样的 query。_validate/query?rewrite=true 会将其重写为仅 prefix ------ 这里仍然使用 Lucene 的 query syntax 展示:

less 复制代码
`

1.  (title:joe*)^4.0 (artist:joe*)^3.0 (genre:joe*)^2.0
2.  (album:joe*)^1.5 label:joe* (comment:joe*)^0.5

`AI写代码

_explain 随后会显示一个常数评分 = 字段 boost ,而不是 BM25 ------ 因此 title^4 上的 Joey / JOEL 可以超过 Lucene 中使用 tf/idf 进行评分的精确 artist:joe。

你可以在 Elasticsearch 上组装出 Lucene 形状的 bool(对每个 token 使用 term + 仅对最后一个 token 使用 prefix,使用相同的 boost,以及会累加的子句)。它永远不会做到 bit-identical,但排序会更加接近。在本系列中,我们保留 Elasticsearch 默认的 bool_prefix 行为 ------ 如实呈现这种排序差异。

在 Demo 中试用

playground Demo 标签页可以在 Lucene-in-RAM 和 Elasticsearch 之间切换,同时使用相同的 TrackSearch contract。设置 cluster URL(默认为 http://localhost:9200/)和 API key,然后点击保存并建立索引 。LCD 会将 printQuery() 显示为可以复制粘贴的 curl(执行后还会显示 JSON 响应)。修改 mapping 后,请重新建立索引,这样 tracks 上才会真正存在 .raw / .normalized。

当曲库可以放入内存,并且"JVM 旁边的 embedded cache"就是产品时,就继续在进程内使用 Lucene。当相同的 Bean contract 应该超越单个进程而存在时,就使用 Elasticsearch ------ 此时 replicas、共享状态,以及"URL + API key"比将倒排索引保留在 heap 中更加重要。

相同的 Bean。相同的 Bob → Club → facets 流程。减少你与倒排索引之间的 plumbing ------ 但并不是 bit-identical 的评分。

完整 Demo 位于 GitHub:lucene-search-tracks。

原文:Search your beans with Lucene --- Elasticsearch | David Pilato

相关推荐
Elasticsearch6 小时前
最好的 LLM 只有 59% 的时间能写出正确的 Elasticsearch ES|QL。以下是另外 41% 出错的原因
elasticsearch
垚垚学技术_聚焦云原生6 小时前
Ansible Role 生产环境标准目录结构与规范化实践
java·elasticsearch·ansible
小林ixn7 小时前
从 MySQL 的 LIKE 到 ES 倒排索引:一次把全文检索和混合检索讲透
sql·elasticsearch·全文检索·agent·关键词
MayBaymax7 小时前
ES 基础总结
大数据·elasticsearch·搜索引擎
Elastic 中国社区官方博客7 小时前
两个依赖和一个配置块:通过 Prometheus 远程写入将 Spring Boot 指标发送到 Elasticsearch
大数据·数据库·spring boot·elasticsearch·搜索引擎·全文检索·prometheus
SelectDB技术团队8 小时前
ELK 太占磁盘、ES 总报写入拒绝:从归因到可执行的优化清单
大数据·clickhouse·elk·elasticsearch·全文检索·复杂查询·实时更新
frjc18 小时前
数据库选型:如何从众多数据库中选出最理想的那一个
redis·mysql·clickhouse·elasticsearch
xbgRS1 天前
Elasticsearch的分词器
大数据·elasticsearch·搜索引擎
Wx-bishekaifayuan1 天前
springboot户外登山社交小程序19787-计算机课程设计、毕业设计
spring boot·后端·python·spring·elasticsearch·django·课程设计