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

27

我有一个 MongoDB 文档集合,文档形式为

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

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

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

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


那个问题的最佳答案(使用字符串开头锚定的正则表达式)所建议的方法,恰好是我推荐你采用的。为什么这个方法不能解决你的问题呢? - Philipp
@Philipp 但是它只能在一个字段中搜索时有效。而且,我没有要搜索的数组,它是一个字符串。您建议我对要匹配的所有字段进行标记化,并将这些标记存储在数组中吗? - ajay
2
那肯定是最适合查询的解决方案(不过对于更新来说就不太友好了) - Philipp
1
这确实听起来像文本搜索的一个适用案例。你说你在这方面没有找到任何有用的信息。关于文本搜索,一个很好的起点参考是Mongo DBA课程视频,你可以在YouTube上找到(https://www.youtube.com/watch?v=Hjx3eUqU0XE&list=PLGOsbT2r-igm8L5m85zc6BkZjAdzIuC5i&index=10)。 - SDillon
@SDillon 在文本搜索中,我需要提取(部分)匹配的标记。我找不到任何相关的帮助信息。 - ajay
您可以通过使用通配符查询和前缀搜索在所有字段上进行搜索。 - karthik manchala
2个回答

44

简述

想要实现的功能并不容易,因为普通查询无法修改它们返回的字段。虽然有一种解决方案(使用下面的MapReduce内联而不是输出到集合),但除了非常小的数据库外,在实时情况下不可能这样做。

问题

按照原来的写法,普通查询无法真正修改它们返回的字段。但还有其他问题。如果您想在相当不错的时间内进行正则表达式搜索,则需要对所有字段进行索引,这将需要 disproportional 的 RAM 来进行该功能。如果您不对所有字段进行索引,则正则表达式搜索会导致集合扫描,这意味着必须从磁盘加载每个文档,这将花费太多时间,使自动完成变得不方便。此外,多个同时请求自动完成的用户会对后端产生相当大的负载。

解决方案

问题与我已经回答过的一个问题非常相似:我们需要从多个字段中提取出每个单词,删除停用词,并将剩余的单词与找到该单词的相应文档的链接一起保存在集合中。现在,为了获取自动完成列表,我们只需查询索引的单词列表。

步骤1:使用MapReduce作业提取单词

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步:查询自动完成
对于自动完成,我们只需要单词,不需要链接到文档。由于单词已经被索引,我们使用覆盖查询 - 只从索引中回答的查询,通常驻留在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有点复杂),但从概念上讲,它实际上相当简单。基本上,你正在为速度交换实时结果(无论如何,除非你花费大量RAM,否则你不会拥有实时结果)。在我看来,这是一个很好的交易。为了使代价高昂的mapReduce阶段更加高效,实施增量式mapReduce可能是一种方法——改进我所谓的“黑客”mapReduce也可能是另一种方法。

最后,这种方式总体上是一种相当丑陋的hack。你可能想深入研究elasticsearch或lucene。在我看来,这些产品更适合你想要的。


非常感谢您提供如此详细的答案 :) 正是我所需要的。我刚刚在研究 Elasticsearch,并发现它更适合我的目的,但是暂时这样就可以了 :) - ajay
2
@ajay 很高兴我能帮到你。老实说,这是一篇不错的学徒作品。请注意,尽管您无法更接近实时结果,但 Elastic Search 并不提供实时结果。 - Markus W Mahlberg
@MarkusWMahlberg:这个解决方案适用于哪些数据大小?我有一个包含100万个字符串的字典,其中大部分由一个或两个单词组成(平均12个字符),全部运行在最小的谷歌云机器上。 - Silver
1
@Silver,使用增量式Map Reduce,我们只受限于RAM和磁盘大小。1M * 12字节=12MB。即使我们将其加倍,我们仍然在谈论可以忽略不计的RAM消耗。但是像往常一样:您必须进行测试。索引压缩在使用wiredTiger时可用,可能会有所帮助。但是说实话,我没有运行任何基准测试或测试消耗。我必须承认,你基本上是自己一个人,尽管我很乐意提供帮助。 - Markus W Mahlberg
1
非常好,它按照我的期望工作,并且具有非常高的性能。我创建了一个聚合管道,完全可以实现这个链接,因为map-reduce现在已被标记为过时。 - Curcuma_
1
@Curcuma_ 如果您将您的努力添加为答案,这将更有帮助--并且可以得到赞同;) - Markus W Mahlberg

0

感谢@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()
    }
}

请检查是否有极小的更改以方便您。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接