【问题标题】:Implement auto-complete feature using MongoDB search使用 MongoDB 搜索实现自动完成功能
【发布时间】:2015-07-05 17:47:18
【问题描述】:

我有一个MongoDB 表单文档集合

{
    "id": 42,
    "title": "candy can",
    "description": "canada candy canteen",
    "brand": "cannister candid",
    "manufacturer": "candle canvas"
}

我需要通过匹配除id 之外的字段来实现基于输入搜索词的自动完成功能。例如,如果输入词是can,那么我应该将文档中所有匹配的words返回为

{ hints: ["candy", "can", "canada", "canteen", ...]

我查看了this question,但没有帮助。我还尝试搜索如何在多个字段中进行 regex 搜索并提取匹配标记,或者在 MongoDB text search 中提取匹配标记,但找不到任何帮助。

【问题讨论】:

  • 该问题的最佳答案所暗示的(带有字符串开头锚点的正则表达式)正是我建议你做的。为什么这不能解决您的问题?
  • @Philipp 但仅在一个字段中搜索时有效。另外,我没有要搜索的数组,它是一个字符串。您是否建议我标记所有要匹配的字段,并将这些标记存储在数组中?
  • 这绝对是最适合查询的解决方案(不过对更新不太友好)
  • 这听起来确实像文本搜索的用例。你说你没有发现任何有用的东西。文本搜索的一个很好的入门参考是通过 youtube (youtube.com/…) 获得的有关此主题的 Mongo DBA 课程视频。
  • @SDillon 通过文本搜索,我需要提取(部分)匹配的标记。我找不到任何关于此的帮助。

标签: regex mongodb autocomplete


【解决方案1】:

tl;博士

没有简单的解决方案可以满足您的需求,因为普通查询无法修改它们返回的字段。有一个解决方案(使用下面的 mapReduce 内联而不是对集合进行输出),但是除了非常小的数据库之外,不可能实时执行此操作。

问题

正如所写,普通查询不能真正修改它返回的字段。但还有其他问题。如果您想在中途进行正则表达式搜索,则必须索引 all 字段,这将需要不成比例的 RAM 量来实现该功能。如果您不索引所有字段,正则表达式搜索会导致collection scan,这意味着必须从磁盘加载每个文档,这将花费太多时间来方便自动完成.此外,多个同时请求自动完成的用户会在后端产生相当大的负载。

解决办法

问题与one I have already answered 非常相似:我们需要从多个字段中提取每个单词,删除stop words 并将剩余单词连同指​​向该单词所在文档的链接一起保存一个集合。现在,为了获取自动补全列表,我们只需查询索引词列表。

第 1 步:使用 map/reduce 作业提取单词

db.yourCollection.mapReduce(
  // Map function
  function() {

    // We need to save this in a local var as per scoping problems
    var document = this;

    // You need to expand this according to your needs
    var stopwords = ["the","this","and","or"];

    for(var prop in document) {

      // We are only interested in strings and explicitly not in _id
      if(prop === "_id" || typeof document[prop] !== 'string') {
        continue
      }

      (document[prop]).split(" ").forEach(
        function(word){

          // You might want to adjust this to your needs
          var cleaned = word.replace(/[;,.]/g,"")

          if(
            // We neither want stopwords...
            stopwords.indexOf(cleaned) > -1 ||
            // ...nor string which would evaluate to numbers
            !(isNaN(parseInt(cleaned))) ||
            !(isNaN(parseFloat(cleaned)))
          ) {
            return
          }
          emit(cleaned,document._id)
        }
      ) 
    }
  },
  // Reduce function
  function(k,v){

    // Kind of ugly, but works.
    // Improvements more than welcome!
    var values = { 'documents': []};
    v.forEach(
      function(vs){
        if(values.documents.indexOf(vs)>-1){
          return
        }
        values.documents.push(vs)
      }
    )
    return values
  },

  {
    // We need this for two reasons...
    finalize:

      function(key,reducedValue){

        // First, we ensure that each resulting document
        // has the documents field in order to unify access
        var finalValue = {documents:[]}

        // Second, we ensure that each document is unique in said field
        if(reducedValue.documents) {

          // We filter the existing documents array
          finalValue.documents = reducedValue.documents.filter(

            function(item,pos,self){

              // The default return value
              var loc = -1;

              for(var i=0;i<self.length;i++){
                // We have to do it this way since indexOf only works with primitives

                if(self[i].valueOf() === item.valueOf()){
                  // We have found the value of the current item...
                  loc = i;
                  //... so we are done for now
                  break
                }
              }

              // If the location we found equals the position of item, they are equal
              // If it isn't equal, we have a duplicate
              return loc === pos;
            }
          );
        } else {
          finalValue.documents.push(reducedValue)
        }
        // We have sanitized our data, now we can return it        
        return finalValue

      },
    // Our result are written to a collection called "words"
    out: "words"
  }
)

针对您的示例运行此 mapReduce 将导致 db.words 如下所示:

    { "_id" : "can", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "canada", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "candid", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "candle", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "candy", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "cannister", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "canteen", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }
    { "_id" : "canvas", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }

请注意,单个单词是文档的_id_id 字段由 MongoDB 自动索引。由于尝试将索引保存在 RAM 中,我们可以采取一些技巧来加快自动完成并减少服务器的负载。

第 2 步:查询自动补全

对于自动完成,我们只需要单词,不需要文档的链接。 由于单词已编入索引,因此我们使用 covered query - 仅从索引中回答的查询,该索引通常驻留在 RAM 中。

为了坚持您的示例,我们将使用以下查询来获取自动完成的候选者:

db.words.find({_id:/^can/},{_id:1})

这给了我们结果

    { "_id" : "can" }
    { "_id" : "canada" }
    { "_id" : "candid" }
    { "_id" : "candle" }
    { "_id" : "candy" }
    { "_id" : "cannister" }
    { "_id" : "canteen" }
    { "_id" : "canvas" }

使用.explain()方法,我们可以验证这个查询只使用了索引。

        {
        "cursor" : "BtreeCursor _id_",
        "isMultiKey" : false,
        "n" : 8,
        "nscannedObjects" : 0,
        "nscanned" : 8,
        "nscannedObjectsAllPlans" : 0,
        "nscannedAllPlans" : 8,
        "scanAndOrder" : false,
        "indexOnly" : true,
        "nYields" : 0,
        "nChunkSkips" : 0,
        "millis" : 0,
        "indexBounds" : {
            "_id" : [
                [
                    "can",
                    "cao"
                ],
                [
                    /^can/,
                    /^can/
                ]
            ]
        },
        "server" : "32a63f87666f:27017",
        "filterSet" : false
    }

注意indexOnly:true 字段。

第三步:查询实际文档

虽然我们必须执行两次查询来获取实际文档,但由于我们加快了整个过程,用户体验应该足够好。

步骤3.1:获取words集合的文档

当用户选择自动补全选项时,我们必须查询单词的完整文档,以便找到自动补全选择的单词的来源文档。

db.words.find({_id:"canteen"})

这将导致这样的文档:

{ "_id" : "canteen", "value" : { "documents" : [ ObjectId("553e435f20e6afc4b8aa0efb") ] } }

步骤 3.2:获取实际文档

使用该文档,我们现在可以显示带有搜索结果的页面,或者像在本例中一样,重定向到您可以通过以下方式获取的实际文档:

db.yourCollection.find({_id:ObjectId("553e435f20e6afc4b8aa0efb")})

注意事项

虽然这种方法一开始可能看起来很复杂(好吧,mapReduce 有点),但实际上在概念上很简单。基本上,您是在交易实时结果(除非您花费 lot 的 RAM,否则无论如何都不会获得)以换取速度。恕我直言,这很划算。为了使成本相当高的 mapReduce 阶段更高效,实现Incremental mapReduce 可能是一种方法——改进我公认的被黑的 mapReduce 很可能是另一种方法。

最后但并非最不重要的一点是,这种方式完全是一个相当丑陋的 hack。您可能想深入研究 elasticsearch 或 lucene。恕我直言,这些产品更适合您的需求。

【讨论】:

  • 非常感谢您提供如此详细的答案 :) 正是我所需要的。我只是在研究弹性搜索,发现它更适合我的目的,但是暂时,这会做:)
  • @ajay 很高兴我能帮上忙。老实说,这是一个很好的学徒作品。请注意,弹性搜索不会提供实时结果,尽管您不会更接近它们,恕我直言。
  • @MarkusWMahlberg:该解决方案适用于哪些数据大小?我有一本包含 100 万个字符串的字典,主要由一两个单词(平均 12 个字符)组成,全部在最小的谷歌云机器上运行
  • @Silver 使用增量映射减少,我们仅受 RAM 和磁盘大小的限制。 1M * 12 字节 = 12MB。让我们甚至翻倍,我们仍然在谈论可以忽略不计的 RAM 消耗。但一如既往:你必须测试。当您使用wiredTiger 时,从3.0 开始可用的索引压缩在这里可能会有所帮助。但老实说,我没有运行任何基准测试或测试消费。我不得不承认,尽管我很乐意提供帮助,但您几乎是靠自己的。
猜你喜欢
  • 2021-12-06
  • 2015-07-20
  • 2013-02-17
  • 2022-08-15
  • 1970-01-01
  • 2012-06-16
  • 1970-01-01
  • 2021-04-14
  • 2021-03-31
相关资源
最近更新 更多