Ohhnews

分类导航

$ cd ..
foojay原文

MongoDB 搜索服务器改进版

#mongodb#搜索服务#java#聚合管道#全文搜索

本文将重新审视并改进最初在《如何使用 Java 与 MongoDB 构建搜索服务》一文中描述的 MongoDB 搜索服务。

首先,让我们提醒自己,为什么一个中间搜索服务值得我们的关注和投入:搜索支撑着应用最重要的部分——快速把用户带到他们需要的内容之前。搜索栏常常是服务的主要入口。搜索结果可以返回向用户展示所需的全部信息,而不必再去查询源数据库。既然搜索独自承担着如此重要且繁重的负载,将搜索服务分离为一个可独立扩展的层级,就可以让我们按需控制、扩展和版本化搜索服务器。从应用程序到 MongoDB Search 之间设置一个网关,还能让我们只传关键参数即可简化接口,而无需“纠缠”于聚合管道语法的细节之中。

无论你的环境中是否需要这种搜索服务,本文以及服务代码本身都能带来以下好处:

  • 揭示各种实现考量(例如,返回分面统计时,附带搜索结果和不附带搜索结果的做法差异很大)
  • 如何从查询字符串和可选过滤条件构建复杂的复合查询(跨多个字段查询,每个字段均可自定义权重)
  • 基本的参数约束检查(例如,确保 $limit 不超过允许的最大值)
  • 对于 Java 开发者:如何使用 BSON API 在代码中构造并执行聚合管道。为什么不用搜索专用的驱动便捷方法?

新增了哪些内容?

自这个搜索服务的最初版本发布以来,又新增了几项功能。v1.1 里程碑记录了自最初实现以来的所有变更。实现的主要搜索功能包括:

  • 分面(Faceting)
  • 排序
  • 高亮
  • 加权搜索字段
  • 否定/排除过滤器

另外,还加入了以下基础设施与部署特性:

  • HTTP POST 支持:提交 ac3b656
  • Docker 部署选项:提交 c47459b

此外,还调整和修复了一些问题:响应中的 meta 部分不再是数组(原先是一个单值数组);q 改为可选,以支持对整个集合进行分面;并修复了 limit = 0 时的 bug。

以下是当前支持的参数,其中大部分为可选参数:

参数描述
q全文查询,通常是用户输入到搜索框中的值。
search使用 q 参数进行搜索的字段列表,以逗号分隔。
skip跳过指定数量的结果后返回结果(最多返回 limit 条结果),最多可跳过 100 条。
limit最多只返回指定数量的结果,上限为 25 条。
project每个文档要返回的字段列表,以逗号分隔。如果需要 _id,请加上 _id_score 是一个“伪字段”,用于包含计算出的相关性得分。
filter语法为 <字段名>:<精确值>;支持零个或多个 filter 参数。在最前面加一个减号(-)可否定该过滤器。
sort<字段名> asc/desc[, <字段名> asc/desc...]。使用 _score 可按相关性得分排序。默认值:_score desc
highlight要用查询词高亮显示的(字符串)字段名列表,以逗号分隔。
debug如果为 true,响应中还会包含完整的聚合管道 .explain() 输出。
facet.string.<label>=<field name><field name> 的字符串值进行分面;该字段必须已作为 token 类型建立索引。
facet.string.<label>.numBuckets为对应的 facet.string.<label> 字段返回的字符串分面桶数量。默认值:10
facet.number.<label>=<path>facet.date.<label>=<path><field name> 的数值或日期值进行分面。
facet.number.<label>.boundaries=...facet.date.<label>.boundaries=...数值或日期边界。作为分面桶边界的递增数字或日期列表,以逗号分隔。
facet.number.<label>.default=<name>facet.date.<label>.default=<name>用于统计落在起始/结束边界之外的分面计数的分面键名称。默认值:不返回计数。

BSON 与搜索专用驱动便捷方法的对比

由于这篇文章主要面向 Java 读者,下一个主题就很重要,因为它关系到代码的可读性,并且会进入一个我们都可以畅所欲言的“主观判断”地带。在两种不同语言或协议之间搭桥的框架或库,往往会遇到阻抗失配的问题,使得一种环境中更自然的 API/接口被抽象、隐藏、简化,以适应另一种环境。在本例中,我们是将 HTTP 参数桥接到 MongoDB Search 功能的一个狭小切片上。这本身就是一种失配:将扁平的键/值对,最终映射为包含聚合管道阶段、搜索操作符、分面规范等在内的类 JSON 表达式。我们做这项工作的目的,是向应用程序层暴露一个精简、干净的 API,使其支持查询字符串,返回跨多个字段加权的搜索结果,同时不必暴露更深层的语法和复杂性,并且受到数据护栏的保护,等等。

随着该里程碑中代码的演进,我们从文章《Atlas Searching with the Java Driver》中吸取了一些经验。这个服务器使用 Java 编写,并利用 MongoDB 健壮的 Java 驱动连接数据库和执行查询。整个过程就是把一个 HTTP 请求对象(扁平的键/值对)转换为聚合管道的 BSON 表示。以下是输入和输出的代码片段:

$ java
MongoCollection<Document>
collection = database.getCollection(collectionName);


Document searchStage = new Document(searchMeta ? "$searchMeta" : "$search", searchStageDoc);

List<Bson> pipeline = new ArrayList<>();
pipeline.add(searchStage);


AggregateIterable<Document> aggregationResults = collection.aggregate(pipeline);

其余代码只是把 HttpServletRequest 变形为管道。

Compass/Atlas UI——将管道导出为代码的“new Document”狂热

最初,这个项目尽可能多地使用 Java 驱动中 MongoDB Search 专用方法。但随着能力不断发展且变得更加动态——例如动态控制分面参数(这不是每个项目都需要的)——维护两种代码风格变得越来越令人头疼。一种风格使用语法化和类型安全的搜索专用 API,另一种则使用更粗糙、但最终更接近“裸金属”的 BSON API。

在添加字符串分面功能时,这种 API 的不便已经到了无法忍受的地步,于是所有对 com.mongodb.client.model.search 的使用都被移除。例如,下面这段代码:

$ java
   SearchOptions options = SearchOptions.searchOptions()
        .option("scoreDetails", debug)
        .index(indexName)

变成了这样:

$ java
Document searchStageDoc= new Document("scoreDetails", debug)
                            .append("index", indexName)

前者既参数名安全,又类型安全,并且可以说读起来更好一些;但对于搜索包提供的语法糖不支持的参数,你终究还是要回到 BSON。(Java)编码本身就是一项充满主观判断的工作,而且它毕竟是“软”件,所以可以轻松调整;至于如何生成 BSON,那就是各取所需了。

编写一个用来适配数据库或搜索引擎的 API 是困难的。API 总是滞后于最新特性和语法,而且它自身也有主观偏向,并带有会泄漏的抽象

结构化 BSON 是 MongoDB 的 lingua franca(通用语言),其他一切不过是达成这一目标的手段。直接使用 BSON 需要更多一点自律,以确保管道和参数被正确构造。

在此提交中,Java 驱动的 .search 便捷方法被替换为 BSON DOM:6db2903

分面(Faceting)

在这三次提交中,实现了所有三种分面模式:

  • 6db2903q 变成可选,实现了字符串分面
  • 532f75b:增加数值分面
  • 6799ec4:实现日期分面

对受支持的字段——number、date 以及映射为 token 的字符串——进行分面,只需一两个参数即可完成。例如,以下是按电影数量排序的前 3 个类型:

/search?facet.string.genres=genres&limit=0&facet.string.genres.numBuckets=3 [LOADING...]

搜索会约束分面计数;在本例中,因为没有提供查询条件,所以分面计数覆盖了整个集合。这种无操作符模式(隐式地匹配所有文档的查询)是一项值得利用的宝贵能力。

在内部,如果 limit 为 0,管道会从 $search 切换到 $searchMeta,因为此时无需返回任何文档,只需要返回分面计数。无论 limit 取值如何,这种内部优化都不会改变搜索服务的响应格式。

字符串分面是最简单的。你只需要一个字段名,以及一个可选的、想要返回的分面桶数量。

数值与日期分面

数值和日期以相同方式进行分面:通过指定一系列递增的边界值来定义分面桶。此外,当提供了默认标签时,会返回另一个带有该标签的分面桶,其中包含落在起始边界到结束边界范围之外的文档计数。

/search?limit=0&facet.number.num_years=year&facet.number.num_years.boundaries=0,1800,1900,1950,1960&facet.number.num_years.default=other [LOADING...]

有趣的是,分面可以用来从各个维度交叉检查数据集,或用来发现异常。例如,在 1800-1899(<= 1900)年份范围内有 2 篇文档——这是真的吗?还是数据有问题?分面让这些分桶角落中的情况无所遁形——你的数据里有什么?

日期分面使用与数值分面相同的风格:使用边界和一个可选的默认桶标签。日期目前使用简单的 YYYY-MM-DD 日期格式,并以 UTC 当日零点作为时间部分。在撰写本文时,通过此服务进行日期分面只支持天级粒度;已经有人提交工单请求支持完整的日期/时间粒度

排序与高亮

排序和高亮可以在同一个请求中完成;解析后的/默认参数会显示在响应的请求部分中:

/search?q=sunshine&project=title,plot,year&search=title,plot&sort=year%20asc&highlight=title,plot [LOADING...]

highlight 参数是一个以逗号分隔的字段名列表,用于高亮查询词。高亮片段会返回到每个返回文档的 _highlights 部分。详见提交 ea58891

sort 参数使用 <field name> asc/desc 语法,就像示例请求中的 sort=year asc,表示按年份升序排序;使用 desc 表示降序。这些值会映射到 $search.sort 中使用的 0 和 1 值。还可以使用 _score 这个特殊的“字段名”来按计算出的相关性得分排序。默认隐式为 _score desc。该功能是在提交 a7a7edf 中添加的。

超越基础

到目前为止,我们只介绍了这个搜索服务中从 HTTP 查询字符串参数到搜索管道的直接映射功能。现在,让我们来看几个常见的搜索最佳实践,它们不只是简单的参数映射翻译。

否定/排除过滤器

你的内容带有可用于过滤的元数据,例如让用户快速、轻松地导航到特定类别的所有文档。在此服务的第一个版本中,基于字符串字段值筛选一组特定文档是通过 filter=<field>:<value> 语法实现的。该过滤器的包含逻辑会转换为所构造搜索操作符中的 compound.filter.equals 子句。

同样方便的是能够选择不属于某个特定类别的文档。其机制有所不同,需要改为使用 compound.mustNot.equals 子句。这种常见模式可以通过在查询参数前添加一个减号来干净地处理。要查找所有不属于 Drama(剧情)类型的电影,使用 &filter=-genres:Drama 即可,就是这么简单。

加权字段

相关性很重要,非常重要!但这并不是任何搜索系统都能免费提供的能力。搜索结果相关性的质量,衡量的是针对你的数据和你的用户,结果有多好用;在测量、监控和调优方面,它值得你细致关注。通常,相关性的一大显著改进来自对搜索字段进行加权,使查询中的匹配词,例如出现在电影标题中时,权重高于可能出现在剧情字段中的相同词语。

下面是对每个搜索字段都可用的新 <field>^<weight> 语法,为 title 字段增加额外权重(分数提升子句)的并排对比。可以看到,只需给 title 字段稍多一点权重,搜索结果的排序就有了明显改善: [LOADING...] [LOADING...]

如果你使用的不是已经支持加权字段的搜索服务器,强烈建议你在自己的应用程序中构建这一能力。在这个服务器的搜索字段支持中加入这么一点简单语法,就能极大提高返回结果的灵活性和质量。

学习机会与生产就绪

虽然这个服务的代码量不大,但安全收益、参数清理和关注点分离,在以搜索为中心的生产部署中可以发挥关键作用。这个服务代码是一个个人项目,用于以通用且面向生产的方式演示 MongoDB Search 和 Java 最佳实践。它已经可以按你的需要部署——它能按设计工作;如果符合你的需求,不妨一试。也许你只需要根据自身情况做一两处微调,就可以把它作为很好的起点。即使这个服务原样并不完全符合你的需求,其中的思路和实现也能为你自己与搜索交互的应用程序提供有益的思考素材。

你可以向自己的搜索代码提出几个有用的问题:

  • 用户/请求传入的参数是否经过清理?
  • 如何将搜索结果和分面结合在同一个请求中?
  • 能否轻松地排查和调整聚合管道的生成?## 启动与运行

您可以使用 Gradle 的 jettyRun 在本地运行,并通过环境变量为系统指定相关配置:

$ bash
MONGODB_URI="<insert your connection string here>" DATABASE=sample_mflix COLLECTION=movies INDEX=movies_index ./gradlew jettyRun

或者构建一个包含 .war 文件的 Docker 镜像,并使用同样的指定环境变量运行。movies.env 文件包含了示例电影数据集对应的 DATABASECOLLECTIONINDEX 名称。

$ bash
./gradlew war
docker build -t search-server .
export MONGODB_URI="<insert your MongoDB connection string here>"
docker run -d -e MONGODB_URI --env-file ./movies.env -p 8080:8080 search-server

使用 Docker 方案时,可以部署多个搜索服务,在同一配置下进行负载均衡,或运行不同的配置。每个服务部署都被锁定在其部署时指定的唯一 INDEX 上,且仅提供读取/搜索功能。

服务启动后,http://<host>:8080/ 首页会包含示例链接(HTTP GET)及一个搜索表单(HTTP POST),它们都适用于示例配置和数据。

搜索服务实战

在这个健壮的示例中,只需指定相关参数,配置的简洁性便一目了然,同时它用到了所有新增与改进的功能。请求 http://localhost:8080/search?q=purple%20rain&project=title,year&search=title^2,plot&limit=2&facet.string.genres=genres&facet.string.genres.numBuckets=3&highlight=title&sort=year%20asc&filter=-genres:Comedy 的含义是:

返回与查询 “purple rain”(注意:该查询会被解析为 “purple” 或 “rain” 的检索)在 titleplot 字段中最相关的两篇文档;其中 title 字段的得分略有提升;对 title 字段中的查询词进行高亮;按 year 排序;排除 Comedy 电影;并对 genres 进行分面统计。

该搜索服务生成了将近 100 行(格式化后的)聚合管道 JSON:

[
 {
   "$search": {
     "scoreDetails": false,
     "index": "movies_index",
     "count": {
       "type": "total"
     },
     "sort": {
       "year": 1
     },
     "highlight": {
       "path": [
         "title"
       ]
     },
     "facet": {
       "facets": {
         "genres": {
           "type": "string",
           "path": "genres",
           "numBuckets": 3
         }
       },
       "operator": {
         "compound": {
           "mustNot": [
             {
               "equals": {
                 "path": "genres",
                 "value": "Comedy"
               }
             }
           ],
           "should": [
             {
               "text": {
                 "query": "purple rain",
                 "path": "title",
                 "score": {
                   "boost": {
                     "value": 2.0
                   }
                 }
               }
             },
             {
               "text": {
                 "query": "purple rain",
                 "path": "plot"
               }
             }
           ]
         }
       }
     }
   }
 },
 {
   "$skip": 0
 },
 {
   "$limit": 2
 },
 {
   "$facet": {
     "docs": [
       {
         "$project": {
           "title": 1,
           "year": 1,
           "_id": 0,
           "_highlights": {
             "$meta": "searchHighlights"
           }
         }
       }
     ],
     "meta": [
       {
         "$limit": 1
       },
       {
         "$replaceWith": "$$SEARCH_META"
       }
     ]
   }
 },
 {
   "$set": {
     "meta": {
       "$arrayElemAt": [
         "$meta",
         0
       ]
     }
   }
 }
]

下面的响应包含 request 部分(显示正在使用的解析后参数)、docs 部分(一个按请求字段投影后的文档数组),以及 meta 部分(包含搜索结果总数与分面统计):

{
 "request": {
   "q": "purple rain",
   "skip": 0,
   "limit": 2,
   "search": "title^2,plot",
   "project": "title,year",
   "filter": [
     "-genres:Comedy"
   ],
   "sort": "year asc",
   "highlight": "title",
   "facet.string.genres": "genres",
   "facet.string.genres.numBuckets": "3"
 },
 "docs": [
   {
     "title": "The Rains Came",
     "year": 1939,
     "_highlights": [
       {
         "score": 1.3891443014144897,
         "path": "title",
         "texts": [
           {
             "value": "The ",
             "type": "text"
           },
           {
             "value": "Rains",
             "type": "hit"
           },
           {
             "value": " Came",
             "type": "text"
           }
         ]
       }
     ]
   },
   {
     "title": "A Hatful of Rain",
     "year": 1957,
     "_highlights": [
       {
         "score": 1.382846713066101,
         "path": "title",
         "texts": [
           {
             "value": "A Hatful of ",
             "type": "text"
           },
           {
             "value": "Rain",
             "type": "hit"
           }
         ]
       }
     ]
   }
 ],
 "meta": {
   "count": {
     "total": 45
   },
   "facet": {
     "genres": {
       "buckets": [
         {
           "_id": "Drama",
           "count": 30
         },
         {
           "_id": "Adventure",
           "count": 8
         },
         {
           "_id": "Thriller",
           "count": 8
         }
       ]
     }
   }
 }
}

结论与后续步骤

一个简单的 HTTP 请求配上少量参数,就能返回一份独立的搜索结果文档;MongoDB Search 的最佳实践、参数清理与管道构建复杂度,都被干净地封装在一个可扩展的服务中。

这个项目还规划了更多便利功能和特性。欢迎关注 https://github.com/mongodb-developer/mongodb-search-java-server/,并告诉我们哪些功能对您很重要。

本文 MongoDB Search Server, improved! 首发于 foojay