使用 MongoDB 搜索实现自动完成功能

Implement auto-complete feature using MongoDB search

我有一个 MongoDB 形式的文档集合

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

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

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

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

tl;博士

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

问题

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

解决方案

问题与 : We need to extract every word out of multiple fields, remove the stop words 非常相似,将剩余的单词与 link 一起保存到在集合中找到单词的相应文档中。现在,为了获得自动完成列表,我们只需查询索引词列表即可。

第 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 步:查询自动完成

对于自动完成,我们只需要单词,不需要文档的 links。 由于单词已编入索引,我们使用 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 步:查询实际文档

尽管我们必须执行两次查询才能获得实际文档,但由于我们加快了整个过程,所以用户体验应该足够好。

步骤 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 可能是另一种方法。

最后但同样重要的是,这种方式完全是一种相当丑陋的黑客攻击。您可能想深入研究 elasticsearch 或 lucene。恕我直言,这些产品非常非常适合您的需求。

感谢@Markus 的解决方案,我想出了类似聚合的方法。知道 map-reduce 被标记为在更高版本中已弃用。

const { MongoDBNamespace, Collection } = require('mongodb')
//.replace(/(\b(\w{1,3})\b(\W|$))/g,'').split(/\s+/).join(' ')
const routine = `function (text) {
    const stopwords = ['the', 'this', 'and', 'or', 'id']
    text = text.replace(new RegExp('\b(' + stopwords.join('|') + ')\b', 'g'), '')
    text = text.replace(/[;,.]/g, ' ').trim()
    return text.toLowerCase()
}`
// If the pipeline includes the $out operator, aggregate() returns an empty cursor.
const agg = [
    {
        $match: {
            a: true,
            d: false,
        },
    },
    {
        $project: {
            title: 1,
            desc: 1,
        },
    },
    {
        $replaceWith: {
            _id: '$_id',
            text: {
                $concat: ['$title', ' ', '$desc'],
            },
        },
    },
    {
        $addFields: {
            cleaned: {
                $function: {
                    body: routine,
                    args: ['$text'],
                    lang: 'js',
                },
            },
        },
    },
    {
        $replaceWith: {
            _id: '$_id',
            text: {
                $trim: {
                    input: '$cleaned',
                },
            },
        },
    },
    {
        $project: {
            words: {
                $split: ['$text', ' '],
            },
            qt: {
                $const: 1,
            },
        },
    },
    {
        $unwind: {
            path: '$words',
            includeArrayIndex: 'id',
            preserveNullAndEmptyArrays: true,
        },
    },
    {
        $group: {
            _id: '$words',
            docs: {
                $addToSet: '$_id',
            },
            weight: {
                $sum: '$qt',
            },
        },
    },
    {
        $sort: {
            weight: -1,
        },
    },
    {
        $limit: 100,
    },
    {
        $out: {
            db: 'listings_db',
            coll: 'words',
        },
    },
]
// Closure for db instance only
/**
 *
 * @param { MongoDBNamespace } db
 */
module.exports = function (db) {
    /** @type { Collection } */
    let collection
    /**
     * Runs the aggregation pipeline
     * @return {Promise}
     */
    this.refreshKeywords = async function () {
        collection = db.collection('listing')
        // .toArray() to trigger the aggregation
        // it returns an empty curson so it's fine
        return await collection.aggregate(agg).toArray()
    }
}

为方便起见,请检查是否有非常微小的更改。