Ohhnews

分类导航

$ cd ..
foojay原文

使用 MongoDB Atlas Search 构建 Java 分面全文搜索 API

#java#mongodb#全文搜索#api 开发#数据检索

目录 开始构建! 前提条件 1. 项目设置 2. COCO 数据

3. Java 服务实现 4. Java 搜索

(可选) 重构以获得更好的结构 总结

这将是一个有趣且实用的教程,展示如何构建一个 Java 分面全文搜索 API(类似于支持亚马逊等网站的搜索功能)!

我们将使用一个有趣的数据集,展示如何有效地将机器学习/AI 生成的数据与传统搜索相结合,从而构建出快速、廉价、可重复且直观的搜索引擎。

TL;DR:如果你(像我一样!)不爱看文字,更喜欢看代码,可以直接上手。查看代码并按照以下方式在本地运行:

$ bash
git clone https://github.com/luketn/atlas-search-coco 
cd atlas-search-coco
docker compose up java-app

(你需要安装 Docker Desktop。)

然后,你可以通过小型示例 UI 进行尝试:

http://localhost:8222/

[LOADING...]

或者直接使用 API:http://localhost:8222/image/search?text=kite&animal=dog

[LOADING...]

开始构建!

好了,让我们一步步完成这个解决方案的构建。到最后,你将掌握构建属于自己的强大分面搜索 API 所需的所有工具。我们将利用 MongoDB Atlas Search 的强大功能,结合 Java 优秀的数据建模和高性能特性来完成它。

前提条件

你的机器上需要具备以下条件:

  • Java 开发工具包 (JDK) 25+ 和 Java IDE
    • 在本教程中,我们将使用 IntelliJ Idea
    • IntelliJ 也可以为你下载并配置 JDK(例如 Amazon Corretto 25)。
    • 如果你喜欢其他 IDE,应该很容易转换这些说明。
  • Docker Desktop
  • MongoDB Compass
  • Apache Maven

1. 项目设置

让我们创建一个新的 Java 项目!打开你的 Java IDE 并创建一个新的基于 Maven 的项目,使用 Java 21。在 IntelliJ 中,选择 File -> New Project...

[LOADING...]## 2. COCO 数据

我们将使用开源的 COCO 图像数据集 作为示例。

你可以点击浏览一下,你会看到一些非常棒的图片,而且(这对我们很重要!)每张图片中包含的对象类别都有详细的说明文字和标签。这里的数据是通过将机器学习模型应用于图像,从而对其中的特征进行分割、标注和分类而生成的。不过,我们的搜索将是词法搜索,速度极快,且不依赖任何机器学习或人工智能模型。

数据模型

在 MongoDB 中处理数据时,最重要的步骤之一就是数据建模。需要考虑集合的结构设计以及每个集合的文档模式。在原始的 COCO 数据集中,存在以下实体,它们各自独立并通过整数 ID 进行关联:

  • Image(图像):包含高度、宽度、图像 URL、许可 ID 和拍摄日期。
  • License(许可):指示图像所属的许可,并链接到许可文档 (1-n)。
  • Annotation (caption)(标注 - 说明文字):包含图像 ID 和描述图像的说明字符串 (n-1)。
  • Annotation (object)(标注 - 对象):包含边界框、图像 ID 和类别 ID (n-1)。
  • Category(类别):包含超类 (superCategory) 和类别名称,例如“交通工具”和“汽车”。

当你在 MongoDB 中进行数据建模时,首要考虑的是应用程序的查询方式。在我们的案例中,我们将公开一个如下所示的 REST API 搜索端点:

/search?vehicle=car&text=red 

在结果中,我们希望获得与分面(类别)和全文(说明文字)参数相匹配的图像的所有详细信息。

为了支持这种预期的查询模式,我们将所有这些实体合并为一个单一的层级化文档模式“Image”,从而使搜索变得高效且简单。

以下是我们定义的 Java Record 模式:

$ java
import java.util.Date;
import java.util.List;

public record Image(
       int _id,
       // 说明文字描述了图像的内容,可以使用全文搜索进行检索
       String caption,
       String url,
       int height,
       int width,
       Date dateCaptured,
       String licenseName,
       String licenseUrl,
       // 如果图像中包含人物,则为 true
       boolean hasPerson,
       // 以下字段为“超类”
       // 列表中的每一项都是一个类别
       // 图像中存在该类别下的一个或多个对象
       List<String> accessory,
       List<String> animal,
       List<String> appliance,
       List<String> electronic,
       List<String> food,
       List<String> furniture,
       List<String> indoor,
       List<String> kitchen,
       List<String> outdoor,
       List<String> sports,
       List<String> vehicle
) { }

我们的 MongoDB 中将有两个集合:

  • Image:包含上述模式的文档。
  • Category:包含可供筛选的类别列表(及其所属的超类)。
$ java
public record Category(
       int _id,
       String superCategory,
       String name
) { }

在你的 Java 项目中创建上述两个 Record(可以使用你喜欢的任何命名空间)。

运行 MongoDB Atlas

你可以选择在 Docker 容器中本地运行 MongoDB Atlas:

$ bash
docker run -d --name mongodb-atlas -p 27017:27017 mongodb/mongodb-atlas-local:8.2.6

本地连接字符串:

mongodb://localhost:27017/?directConnection=true 

或者在云端创建一个 免费的 MongoDB Atlas 集群。(请记下集群的连接字符串,以供后续步骤使用。)

数据导入 MongoDB Atlas

为了简化操作,我已经下载了 COCO 数据集并将其转换为了上述数据模型。

首先,下载并解压 数据

你将获得两个数据文件,分别对应我们的两个集合:

  • AtlasSearchCoco.Category.json
  • AtlasSearchCoco.Image.json

接下来,我们将使用 MongoDB Compass UI 来导入 JSON 数据并创建数据库。

打开 Compass 应用,使用你在上一步中记下的连接字符串连接到 MongoDB,并通过连接旁边的“+”图标创建一个新数据库:

atlasSearchCoco 输入为数据库名称,image 作为初始集合名称:

点击 Create Database 开始。

点击 Import Data 导入我们的 JSON 数据:

选择你下载的 AtlasSearchCoco.Image.json 文件并点击 Import。

你应该会看到一条提示,显示已导入 118,287 个文档。

接下来,通过点击 atlasSearchCoco 数据库旁边的“+”图标来创建 category 集合:

点击 Create Collection

与上述步骤相同,点击 Import Data,但这次选择 AtlasSearchCoco.Category.json 文件:

点击 Import 完成分类数据的导入,你应该会看到一条提示,显示已导入 80 个文档。恭喜!你现在已经拥有了一个加载了 COCO 图像数据集的 MongoDB Atlas 集群。

创建 Atlas Search 索引

接下来,我们将创建 Atlas Search 索引。这是我们后续在 Java 中进行所有操作的核心,因此我们将花一点时间详细介绍索引的每个部分及其含义。要创建索引,请前往 MongoDB Compass UI 中 image 集合的 Indexes 选项卡。点击 Search Indexes 以选择 Atlas Search 索引选项卡:

接下来,点击 Create Atlas Search Index

保持名称为默认值,并粘贴以下索引定义:

$ cat
{
 "mappings": {
   "fields": {
     "caption": [{"type": "string"}],
     "hasPerson": {"type": "boolean"},
     "accessory": [{"type": "token"}, {"type": "stringFacet"}],
     "animal": [{"type": "token"}, {"type": "stringFacet"}],
     "appliance": [{"type": "token"}, {"type": "stringFacet"}],
     "electronic": [{"type": "token"}, {"type": "stringFacet"}],
     "food": [{"type": "token"}, {"type": "stringFacet"}],
     "furniture": [{"type": "token"}, {"type": "stringFacet"}],
     "indoor": [{"type": "token"}, {"type": "stringFacet"}],
     "kitchen": [{"type": "token"}, {"type": "stringFacet"}],
     "outdoor": [{"type": "token"}, {"type": "stringFacet"}],
     "sports": [{"type": "token"}, {"type": "stringFacet"}],
     "vehicle": [{"type": "token"}, {"type": "stringFacet"}]
   }
 }
}

点击 Create Search Index。片刻之后,你应该会看到状态变为 READY,这表明索引已创建并准备好进行搜索。

以下是索引字段及其类型定义的作用:

caption

$ cat
"caption": [{"type": "string"}],

该字段的类型定义非常简单:string。字符串字段可以使用底层的 Lucene 搜索引擎以复杂的方式进行搜索,该字段将是我们 Java 搜索服务主要搜索的对象。当你创建一个 string 类型的字段时,Lucene 会遍历所有文档,将 caption 字段字符串分词为单词词干,并生成一个唯一的短词条列表。这就是 Lucene 文本搜索速度极快的秘诀。每个文档在 Lucene 索引中都被分配了一个整数文档 ID,由于搜索时可以压缩或跳过范围,这些 ID 可以被高效存储。使用 string 类型索引,你可以执行模糊匹配、同义词和通配符等高级文本搜索。

hasPerson

$ cat
"hasPerson": {"type": "boolean"},

这是一个非常简单的索引类型,本质上将文档分为三组:布尔值 true、false 和 undefined。

类别字段 (accessory, animal, appliance, electronic, food, furniture, indoor, kitchen, outdoor, sports, vehicle)

$ cat
"accessory": [{"type": "token"}, {"type": "stringFacet"}],
...

这些字段在集合中都是字符串数组值。例如,在 animal 数组中:"animal": ["dog"]。它们通过两种不同的方式在 Atlas Search 中进行索引:

  • token:Token 类型索引用于可用于精确匹配过滤的值,但不能用于高级文本搜索。这非常适合我们的用例,因为我们将允许 API 将某些类别作为过滤器。
  • stringFacet:StringFacet 类型索引用于计算给定字段值的潜在精确匹配项的数量。我们将使用它来显示如果选中每个类别,将匹配多少文档。

示例搜索

通过将这些字段组合到一个索引中,我们可以(一次性!)对 caption 执行高级文本搜索,按我们想要的任何类别进行过滤,并收集类别的分面计数。例如,这个简化的搜索将查找说明文字中包含“frisbee”(飞盘)且图中包含“dog”(狗)的图像。我们将使用 复合过滤器 (compound filter) 在我们的示例查询中组合多个过滤子句。我们还将使用 分面 (facet) 收集器来计算 animalsports 类别的潜在匹配数量。该查询相当复杂,但目前不必太担心,随着我们用 Java 实现它,我们将深入探讨该查询的每个部分。现在,先在 Compass 中尝试一下,你会注意到查询结果和分面信息是同时返回的。你可以在 image 集合的 Aggregations 选项卡中运行该示例,并将以下 JSON 粘贴到文本视图中:

$ cat
[
 {
   "$search": {
     "facet": {
       "operator": {
         "compound": {
           "filter": [
             {
               "text": {
                 "path": "caption",
                 "query": "frisbee"
               }
             },
             {
               "equals": {
                 "path": "animal",
                 "value": "dog"
               }
             }
           ]
         }
       },
       "facets": {
         "animal": {
           "type": "string",
           "path": "animal",
           "numBuckets": 10
         },
         "sports": {
           "type": "string",
           "path": "sports",
           "numBuckets": 10
         }
       }
     },
     "count": {
       "type": "total"
     }
   }
 },
 {
   "$facet": {
     "docs": [],
     "meta": [
       {
         "$replaceWith": "$$SEARCH_META"
       },
       {
         "$limit": 1
       }
     ]
   }
 }
]

示例结果:

$ cat
{
 "docs": [
   {
     "_id": 394,
     "caption": "A dog is holding a frisbee standing on grass.",
     ...
   },
   ...
 ],
 "meta": [
   {
     "count": { "total": { "$numberLong": "366" } },
     "facet": {
       "sports": {
         "buckets": [
           { "_id": "frisbee", "count": { "$numberLong": "364" } },
           ...
         ]
       },
       "animal": {
         "buckets": [
           { "_id": "dog", "count": { "$numberLong": "366" } }
         ]
       }
     }
   }
 ]
}
```## 3. Java 服务实现

好了!现在我们来实现 Java 服务类。我们直奔主题:Atlas Search 的语法非常复杂,特别是当你涉及多个复合子句和条件时。分面(Faceting)增加了额外的复杂性,而要在一次查询中同时获取搜索结果和分面数据,则进一步加剧了这种复杂性。我认为 MongoDB Java 驱动程序以及 Java 语言本身在几个方面有助于降低这种复杂性:

* **强类型**:一旦确定了数据模型,就可以轻松将其定义为不可变的 Java Record 类型,驱动程序将负责序列化/反序列化。有了这一基础,你就能确信业务逻辑代码是正确且安全的。
* **流式构建器语法**:Atlas Search 中需要使用的大多数操作都有辅助方法,可以帮助你组合搜索操作并构建查询。
* **出色的测试**:在用 NodeJS 和 Python 构建过类似的解决方案后,我发现 Java 在测试解决方案以及代码覆盖率和测试完整性的严谨性方面更容易实现,且支持更好。围绕搜索代码构建一套全面的测试对于明确代码意图、演练正常路径和边缘情况以及防止回归至关重要。

从基础开始,我们创建一个 `EntryPoint` 类,初始化 MongoDB 连接,并返回一些数据:

首先,在 Maven 的 `pom.xml` 中添加 MongoDB、JSON 序列化器(Jackson)以及一个简单的日志框架的依赖:

```xml
<dependencies>
   <dependency>
       <groupId>org.mongodb</groupId>
       <artifactId>mongodb-driver-sync</artifactId>
       <version>5.2.0</version>
   </dependency>
   <dependency>
       <groupId>com.fasterxml.jackson.core</groupId>
       <artifactId>jackson-databind</artifactId>
       <version>2.17.2</version>
   </dependency>
   <dependency>
       <groupId>org.slf4j</groupId>
       <artifactId>slf4j-simple</artifactId>
       <version>2.0.13</version>
   </dependency>
</dependencies>

JSON 序列化器将用于从 API 返回数据,MongoDB 驱动程序将使用 SL4J 记录器将其日志写入控制台(或你配置的其他位置)。接下来,创建 EntryPoint 类:

$ java
package com.mycodefu;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.MongoDatabase;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class Main {

   public static void main(String[] args) throws IOException {

       String connectionString = "mongodb://localhost:27017";
       MongoClient mongoClient = MongoClients.create(connectionString);
       MongoDatabase database = mongoClient.getDatabase("atlasSearchCoco");
       MongoCollection<Category> categoryCollection = database.getCollection("category", Category.class);
       MongoCollection<Image> imageCollection = database.getCollection("image", Image.class);

       ObjectMapper objectMapper = new ObjectMapper();

       HttpServer httpServer = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0);

       httpServer.createContext("/categories", exchange -> {
           List<Category> categories = categoryCollection.find().into(new ArrayList<>());
           String categoriesJson = objectMapper.writeValueAsString(categories);
           byte[] categoriesJsonBytes = categoriesJson.getBytes();

           exchange.sendResponseHeaders(200, categoriesJsonBytes.length); 
           exchange.getResponseBody().write(categoriesJsonBytes);
           exchange.close();
       });

       httpServer.createContext("/images", exchange -> {
           Map<String, List<String>> params = Arrays.stream(exchange.getRequestURI().getQuery().split("&"))
                   .map(param -> param.split("=", 2))
                   .map(pair -> new String[]{
                           URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
                           URLDecoder.decode(pair[1], StandardCharsets.UTF_8)
                   })
                   .collect(Collectors.groupingBy(
                           pair -> pair[0],
                           Collectors.mapping(
                                   pair -> pair[1],
                                   Collectors.toList()
                           )
                   ));

           //TODO: 根据查询参数实现图片搜索。目前我们只返回第一张图片。
           List<Image> images = imageCollection.find().limit(1).into(new ArrayList<>());
           //----------------

           String imagesJson = objectMapper.writeValueAsString(images);
           byte[] imagesJsonBytes = imagesJson.getBytes();

           exchange.sendResponseHeaders(200, imagesJsonBytes.length);
           exchange.getResponseBody().write(imagesJsonBytes);
           exchange.close();
       });

       httpServer.start();

       System.out.println("服务器启动于 http://localhost:8080");
       System.out.println("尝试访问分类列表: http://localhost:8080/categories");
       System.out.println("尝试搜索图片: http://localhost:8080/images?caption=motorcycle");
   }
}

运行应用程序,你应该就能在浏览器中加载这些 URL 了:http://localhost:8080/categories

以及:http://localhost:8080/images

关于主类的实现,有几点需要注意:关于 Mongo 连接、数据库和集合实例——我们做的第一件事是建立与 MongoDB 的连接,并获取集合类的类型化实例,这使我们能够查询数据:

$ java
       String connectionString = "mongodb://localhost:27017";
       MongoClient mongoClient = MongoClients.create(connectionString);
       MongoDatabase database = mongoClient.getDatabase("atlasSearchCoco");
       MongoCollection<Category> categoryCollection = database.getCollection("category", Category.class);
       MongoCollection<Image> imageCollection = database.getCollection("image", Image.class);

CategoryImage 类型是我们之前创建的 Record,代表了我们项目的 COCO 领域模型。我们还创建了一个 ObjectMapper,这是我们将用于向客户端写入 JSON 的 Jackson JSON 序列化器。接下来,我们使用 Java 内置的 HTTP 服务器创建了一个 API 服务器,用于查询数据库并返回 JSON:

$ java
       HttpServer httpServer = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0);

       httpServer.createContext("/categories", exchange -> {
           List<Category> categories = categoryCollection.find().into(new ArrayList<>());
           String categoriesJson = objectMapper.writeValueAsString(categories);
           byte[] categoriesJsonBytes = categoriesJson.getBytes();

           exchange.sendResponseHeaders(200, categoriesJsonBytes.length);
           exchange.getResponseBody().write(categoriesJsonBytes);
           exchange.close();
       });
       httpServer.start();

当然,我们可以在这里使用像 Spring Boot 这样的框架,但为了专注于 MongoDB Atlas Search,我们在这个项目中仅使用这个简单的基础 HTTP 服务器实现。我最喜欢 Java 版 MongoDB 客户端的一点是,我们可以轻松利用类型系统的强大功能。我们定义的不可变 Category Record 所代表的领域模型,在这里被所有与数据库交互的逻辑所强制执行。虽然我们在 MongoDB 中的模式可以自由演变,但我们控制这种演变的地点就在 Java 代码中。对模型的任何更改都是刻意的,并在编译时和运行时在服务中强制执行。通常,我们的客户端(比如浏览器 UI)也会是松散类型的 JSON(不强制执行数据模型)。对我来说,服务层是执行此类控制的正确位置,因此我们的模式会随着 API 的演变而演变。接下来,我们有了图片搜索的占位符:

$ java
httpServer.createContext("/images", exchange -> {
   Map<String, List<String>> params = Arrays.stream(exchange.getRequestURI().getQuery().split("&"))
           .map(param -> param.split("=", 2))
           .map(pair -> new String[]{
                   URLDecoder.decode(pair[0], StandardCharsets.UTF_8),
                   URLDecoder.decode(pair[1], StandardCharsets.UTF_8)
           })
           .collect(Collectors.groupingBy(
                   pair -> pair[0],
                   Collectors.mapping(
                           pair -> pair[1],
                           Collectors.toList()
                   )
           ));

   //TODO: 根据查询参数实现图片搜索。目前我们只返回第一张图片。
   List<Image> images = imageCollection.find().limit(1).into(new ArrayList<>());
   //----------------

   String imagesJson = objectMapper.writeValueAsString(images);
   byte[] imagesJsonBytes = imagesJson.getBytes();

   exchange.sendResponseHeaders(200, imagesJsonBytes.length);
   exchange.getResponseBody().write(imagesJsonBytes);
   exchange.close();
});

在这里,我们添加了一个安全且优雅的方式将查询参数摄取到 Map 中,目前仅返回第一张图片。请注意,Map 的类型是 StringList,这很重要,因为我们的查询参数可能包含同一个参数的 1 到 n 个实例。我们将在下一步中使用它来过滤搜索中的多个值。## 4. Java 搜索 最后一步:搜索!

我们将向模型列表中添加一种新的记录类型,它允许我们同时返回分页的 Image 记录列表以及分面(facet)统计数据:

$ java
import java.util.List;

public record ImageSearchResult(List<Image> docs, List<ImageMeta> meta) {
   public record ImageMeta (ImageMetaTotal count, ImageMetaFacets facet) { }
   public record ImageMetaTotal (long total) { }
   public record ImageMetaFacets (
           ImageMetaFacet accessory,
           ImageMetaFacet animal,
           ImageMetaFacet appliance,
           ImageMetaFacet electronic,
           ImageMetaFacet food,
           ImageMetaFacet furniture,
           ImageMetaFacet indoor,
           ImageMetaFacet kitchen,
           ImageMetaFacet outdoor,
           ImageMetaFacet sports,
           ImageMetaFacet vehicle
   ) { }
   public record ImageMetaFacet (List<ImageMetaFacetBucket> buckets) { }
   public record ImageMetaFacetBucket (String _id, long count) { }
}

将其作为新记录保存到项目中,与 ImageCategory 并列。该记录代表聚合搜索查询返回的结果文档。第一个字段 docs 是图像文档的单页数据,第二个字段 meta 包含匹配搜索条件的所有文档的元数据。元数据展示了匹配文档的总数,以及每个分面的统计数量。以我们的情况为例,图像中可能包含的每类对象都有对应的分面。例如,如果我们搜索图像标题中的“dog”(狗),我们可能会在 animal->dog 分面中看到较高的计数值,但同时也会看到其他分面的计数值。例如,你可能会注意到 sports->surfboard(运动->冲浪板)分面有 68 个匹配项。

了解这一点后,你就可以进一步过滤结果,例如:http://localhost:8080/images?caption=dog&sports=surfboard

或者至少,它确实可以实现!让我们来实现图像搜索 API。由于查询比较复杂,我们在 Main 类中创建一个新方法来处理搜索。将以下代码插入到 main 方法之后:

$ java
private static ImageSearchResult search(MongoCollection<Image> imageCollection, String caption, Integer page, Boolean hasPerson, List<String> accessory, List<String> animal, List<String> appliance, List<String> electronic, List<String> food, List<String> furniture, List<String> indoor, List<String> kitchen, List<String> outdoor, List<String> sports, List<String> vehicle) {
   int skip = 0;
   int pageSize = 5;
   if (page != null) {
       skip = page * pageSize;
   }

   List<SearchOperator> clauses = new ArrayList<>();
   if (caption != null) {
       clauses.add(SearchOperator
               .text(
                       fieldPath("caption"),
                       caption
               )
       );
   }
   if (hasPerson != null) {
       clauses.add(equals("hasPerson", hasPerson));
   }
   BiConsumer<String, List<String>> addConditional = (String category, List<String> values) -> {
       if (values != null) {
           for (String value : values) {
               clauses.add(equals(category, value));
           }
       }
   };
   addConditional.accept("accessory", accessory);
   addConditional.accept("animal", animal);
   addConditional.accept("appliance", appliance);
   addConditional.accept("electronic", electronic);
   addConditional.accept("food", food);
   addConditional.accept("furniture", furniture);
   addConditional.accept("indoor", indoor);
   addConditional.accept("kitchen", kitchen);
   addConditional.accept("outdoor", outdoor);
   addConditional.accept("sports", sports);
   addConditional.accept("vehicle", vehicle);

   List<StringSearchFacet> facets = List.of(
           stringFacet("accessory", fieldPath("accessory")).numBuckets(10),
           stringFacet("animal", fieldPath("animal")).numBuckets(10),
           stringFacet("appliance", fieldPath("appliance")).numBuckets(10),
           stringFacet("electronic", fieldPath("electronic")).numBuckets(10),
           stringFacet("food", fieldPath("food")).numBuckets(10),
           stringFacet("furniture", fieldPath("furniture")).numBuckets(10),
           stringFacet("indoor", fieldPath("indoor")).numBuckets(10),
           stringFacet("kitchen", fieldPath("kitchen")).numBuckets(10),
           stringFacet("outdoor", fieldPath("outdoor")).numBuckets(10),
           stringFacet("sports", fieldPath("sports")).numBuckets(10),
           stringFacet("vehicle", fieldPath("vehicle")).numBuckets(10)
   );

   List<Bson> aggregateStages = List.of(
           Aggregates.search(
                   SearchCollector.facet(
                           SearchOperator.compound().filter(clauses),
                           facets
                   ), SearchOptions.searchOptions().count(SearchCount.total())),
           Aggregates.skip(skip),
           Aggregates.limit(pageSize),
           Aggregates.facet(
                   new Facet("docs", List.of()),
                   new Facet("meta", List.of(
                           Aggregates.replaceWith("$$SEARCH_META"),
                           Aggregates.limit(1)
                   ))
           )
   );

   ImageSearchResult imageSearchResult = imageCollection.aggregate(aggregateStages, ImageSearchResult.class).first();

   return imageSearchResult;
}

private static SearchOperator equals(String fieldName, Object value) {
   return SearchOperator.of(
           new Document("equals", new Document()
                   .append("path", fieldName)
                   .append("value", value)
           ));
}

接下来,从 /image 服务处理程序中调用这个新函数。将你之前留下的 TODO 替换掉:

$ java
ImageSearchResult images = search(imageCollection,
       params.containsKey("caption") ? params.get("caption").getFirst() : null,
       params.containsKey("page") ? Integer.parseInt(params.get("page").getFirst()) : null,
       params.containsKey("hasPerson") ? Boolean.parseBoolean(params.get("hasPerson").getFirst()) : null,
       params.get("accessory"),
       params.get("animal"),
       params.get("appliance"),
       params.get("electronic"),
       params.get("food"),
       params.get("furniture"),
       params.get("indoor"),
       params.get("kitchen"),
       params.get("outdoor"),
       params.get("sports"),
       params.get("vehicle")
);

这段代码会将传递给 API 的查询参数提取出来,并调用我们的搜索函数。运行一下看看效果,然后我们再逐步拆解这个搜索方法。现在你可以开始看到分面是如何工作的了。让我们在图像标题中搜索“riding”(骑行),并进一步过滤出同时包含马和手提箱的图像:http://localhost:8080/images?caption=riding&accessory=suitcase&animal=horse

太棒了。🙂 如果你在操作步骤中遇到任何困难,可以参考教程代码。现在,我们来逐一讲解搜索方法的各个部分。我们在 MongoDB 图像集合上的聚合搜索包含以下阶段:

[
 {
   $search: {
     facet: {
       operator: {
         compound: {
           filter: [ <在此处放入过滤子句!> ]
         }
       },
       facets: { <列出我们希望返回的分面> }
     }
   }
 }, 
 <分页>
 {"$skip": 0},{"$limit": 5},
 <返回结构 - 文档页 + 元数据(分面计数)>
 {$facet: {docs: [], meta: [...]}}
]

你可以在 Java 代码中看到每个阶段的组成:

分页

$ java
   int skip = 0;
   int pageSize = 5;
   if (page != null) {
       skip = page * pageSize;
   }

首先,我们计算分页需要跳过的文档数量,并将页面大小设置为 5。这些值会被传递给 skiplimit 阶段。

过滤子句

在搜索方法的这一部分,我们构建了一个列表,作为过滤子句:

$ java
   List<SearchOperator> clauses = new ArrayList<>();
   // ... (代码见上文)

我们使用了一个小辅助方法 addConditional,它会检查参数是否为 null,如果不为 null,则为每个类别添加一个 equals 子句。另一个小型辅助方法 equals() 用于为我们的 equals 子句构建 Document

第一个子句使用了文本搜索操作符。该操作符功能极其深奥,我们在此不做详述,但你可以对其进行自定义以支持:

  • 简单的文本搜索(即我们在此使用的)。
  • 可配置的模糊文本搜索以处理拼写错误——“ridung” -> “riding”。
  • 通配符,如 “rid*”。
  • 复杂的查询字符串,如 “riding AND NOT (bicycle OR horse)”。
  • 用于查找多词序列的短语搜索。
  • 在单次搜索中组合上述多种方法,并根据匹配结果的交集对结果进行排序。

查看如何自定义搜索的详细信息。

分面

然后,我们汇总了希望返回的分面列表:

$ java
   List<StringSearchFacet> facets = List.of(
           stringFacet("accessory", fieldPath("accessory")).numBuckets(10),
           // ... (其他分面)
   );

这里我们声明希望对在 Atlas Search 索引中通过 stringFacet 索引的所有字段进行计数。我们还将最大分桶数(bucket)设置为 10。这意味着我们将获得每个超类的前 10 个结果及其计数值。例如,假设针对标题“grass”(草)的搜索,有 15 种动物匹配,那么我们只会收到前 10 种动物的计数,最不显著的 5 种将被忽略。

搜索

最后,我们将所有聚合阶段组合在一起并调用搜索:

$ java
// ... (代码见上文)

最有趣的部分,或许也是最令人困惑的部分,是对最终聚合阶段 facet 的使用。这里的“facet”与之前的分面含义不同,它是在为我们的聚合结果创建两个分面。这是 Atlas Search 的一个小魔法,允许我们返回搜索的文档分页,同时返回分面的元数据。最好不要深究其原理,照着写就行。如果你一定要搞清楚,可以参考关于将分面收集器与 $$SEARCH_META 聚合框架变量配合使用的文档。

(可选)重构以获得更好的结构

如果你愿意,可以看看重构后的相同代码,它被划分为几个独立的类,具有更好的整体结构,可以作为实际 API 服务器的基础。该项目还包含了用于下载和转换 COCO 数据集到我们的领域模型并创建索引的代码。如果你深入探索,还会发现一些关于如何使用出色的 Test Containers 项目进行 Atlas Search 单元测试的宝藏。

总结

以上就是全部内容。你已经拥有了一个使用 Atlas Search 执行高级文本搜索、过滤和分面计数的神奇服务的雏形!此处对 COCO 数据集的使用展示了一个有趣的案例,即如何将机器学习生成的数据与更传统的词汇文本搜索相结合。这为你的 API 用户提供了可重复、一致且直观的搜索结果。分面允许我们创建可过滤的结果集,并对每个可过滤类别进行计数,这支持在高性能的单次搜索查询中实现高级用户界面。使用 Java 语言和 Java 虚拟机 (JVM) 作为运行时,为构建可扩展的 API 提供了一个高度一致、强类型且可靠的平台。特别是 Java 的语言特性与 MongoDB 配合得非常好。Java 是强化模式并随时间推移进行演进的绝佳语言。这种代码模式中编译时和运行时的检查一致性,与 MongoDB 数据库模式在演进过程中的灵活性相结合,堪称天作之合。

阅读更多关于 Atlas SearchAtlas Search 分面使用 Docker 在本地运行 Atlas 以及 MongoDB Atlas 搜索功能的 Java 驱动程序的信息。

最后,一窥 Atlas Search 的内部机制

如果你有任何问题或想了解更多信息,请随时在 LinkedIn 上与我联系,并查看我的个人博客页面这里。祝编码愉快!