使用 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()
}
}
为方便起见,请检查是否有非常微小的更改。
我有一个 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,这意味着每个文档都必须从磁盘加载,这会花费太多时间为了方便自动完成。此外,多个同时请求自动完成的用户会在后端产生相当大的负载。
解决方案
问题与
第 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()
}
}
为方便起见,请检查是否有非常微小的更改。