Efter att ha funderat länge på detta tror jag att det går att genomföra vad man vill. Det är dock inte lämpligt för mycket stora databaser och jag har inte utarbetat någon inkrementell metod än. Den saknar stamstam och stoppord måste definieras manuellt.
Tanken är att använda mapReduce för att skapa en samling sökord med hänvisningar till ursprungsdokumentet och fältet där sökordet kommer ifrån. Sedan, för den faktiska frågan för autokomplettering görs med en enkel aggregering som använder ett index och därför bör vara ganska snabb.
Så vi kommer att arbeta med följande tre dokument
{
"name" : "John F. Kennedy",
"address" : "Kenson Street 1, 12345 Footown, TX, USA",
"note" : "loves Kendo and Sushi"
}
och
{
"name" : "Robert F. Kennedy",
"address" : "High Street 1, 54321 Bartown, FL, USA",
"note" : "loves Ethel and cigars"
}
och
{
"name" : "Robert F. Sushi",
"address" : "Sushi Street 1, 54321 Bartown, FL, USA",
"note" : "loves Sushi and more Sushi"
}
i en samling som heter textsearch
.
Karta-/reduceringsstadiet
Vad vi i princip gör är att vi kommer att bearbeta varje ord i ett av de tre fälten, ta bort stoppord och siffror och spara varje ord med dokumentets _id
och fältet för förekomsten i en mellantabell.
Den kommenterade koden:
db.textsearch.mapReduce(
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"];
// This denotes the fields which should be processed
var fields = ["name","address","note"];
// For each field...
fields.forEach(
function(field){
// ... we split the field into single words...
var words = (document[field]).split(" ");
words.forEach(
function(word){
// ...and remove unwanted characters.
// Please note that this regex may well need to be enhanced
var cleaned = word.replace(/[;,.]/g,"")
// Next we check...
if(
// ...wether the current word is in the stopwords list,...
(stopwords.indexOf(word)>-1) ||
// ...is either a float or an integer...
!(isNaN(parseInt(cleaned))) ||
!(isNaN(parseFloat(cleaned))) ||
// or is only one character.
cleaned.length < 2
)
{
// In any of those cases, we do not want to have the current word in our list.
return
}
// Otherwise, we want to have the current word processed.
// Note that we have to use a multikey id and a static field in order
// to overcome one of MongoDB's mapReduce limitations:
// it can not have multiple values assigned to a key.
emit({'word':cleaned,'doc':document._id,'field':field},1)
}
)
}
)
},
function(key,values) {
// We sum up each occurence of each word
// in each field in every document...
return Array.sum(values);
},
// ..and write the result to a collection
{out: "searchtst" }
)
Att köra detta kommer att resultera i skapandet av samlingen searchtst
. Om det redan existerade kommer allt innehåll att ersättas.
Det kommer att se ut ungefär så här:
{ "_id" : { "word" : "Bartown", "doc" : ObjectId("544b9811fd9270c1492f5835"), "field" : "address" }, "value" : 1 }
{ "_id" : { "word" : "Bartown", "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "address" }, "value" : 1 }
{ "_id" : { "word" : "Ethel", "doc" : ObjectId("544b9811fd9270c1492f5835"), "field" : "note" }, "value" : 1 }
{ "_id" : { "word" : "FL", "doc" : ObjectId("544b9811fd9270c1492f5835"), "field" : "address" }, "value" : 1 }
{ "_id" : { "word" : "FL", "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "address" }, "value" : 1 }
{ "_id" : { "word" : "Footown", "doc" : ObjectId("544b7e44fd9270c1492f5834"), "field" : "address" }, "value" : 1 }
[...]
{ "_id" : { "word" : "Sushi", "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "name" }, "value" : 1 }
{ "_id" : { "word" : "Sushi", "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "note" }, "value" : 2 }
[...]
Det finns några saker att notera här. Först och främst kan ett ord ha flera förekomster, till exempel med "FL". Det kan dock finnas i olika dokument, som det är fallet här. Ett ord kan också ha flera förekomster i ett enda fält i ett enda dokument, å andra sidan. Vi kommer att använda detta till vår fördel senare.
För det andra har vi alla fält, framför allt word
fält i ett sammansatt index för _id
, vilket borde göra de kommande frågorna ganska snabba. Men detta betyder också att indexet kommer att vara ganska stort och – som för alla index – tenderar att äta upp RAM.
Aggregeringsstadiet
Så vi har minskat på ordlistan. Nu frågar vi efter en (under)sträng. Vad vi behöver göra är att hitta alla ord som börjar med strängen som användaren hittills skrivit in, vilket returnerar en lista med ord som matchar den strängen. För att kunna göra detta och för att få resultaten i en form som passar oss använder vi en aggregering.
Denna aggregering bör vara ganska snabb, eftersom alla nödvändiga fält för att fråga är en del av ett sammansatt index.
Här är den kommenterade sammanställningen för fallet när användaren skrev in bokstaven S
:
db.searchtst.aggregate(
// We match case insensitive ("i") as we want to prevent
// typos to reduce our search results
{ $match:{"_id.word":/^S/i} },
{ $group:{
// Here is where the magic happens:
// we create a list of distinct words...
_id:"$_id.word",
occurrences:{
// ...add each occurrence to an array...
$push:{
doc:"$_id.doc",
field:"$_id.field"
}
},
// ...and add up all occurrences to a score
// Note that this is optional and might be skipped
// to speed up things, as we should have a covered query
// when not accessing $value, though I am not too sure about that
score:{$sum:"$value"}
}
},
{
// Optional. See above
$sort:{_id:-1,score:1}
}
)
Resultatet av den här frågan ser ut ungefär så här och borde vara ganska självförklarande:
{
"_id" : "Sushi",
"occurences" : [
{ "doc" : ObjectId("544b7e44fd9270c1492f5834"), "field" : "note" },
{ "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "address" },
{ "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "name" },
{ "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "note" }
],
"score" : 5
}
{
"_id" : "Street",
"occurences" : [
{ "doc" : ObjectId("544b7e44fd9270c1492f5834"), "field" : "address" },
{ "doc" : ObjectId("544b9811fd9270c1492f5835"), "field" : "address" },
{ "doc" : ObjectId("544bb320fd9270c1492f583c"), "field" : "address" }
],
"score" : 3
}
Poängen 5 för Sushi kommer från det faktum att ordet Sushi förekommer två gånger i anteckningsfältet i ett av dokumenten. Detta är avsett beteende.
Även om detta kan vara en fattigmanslösning, måste optimeras för de myriader av tänkbara användningsfall och skulle behöva en inkrementell mapReduce som ska implementeras för att vara halvvägs användbar i produktionsmiljöer, fungerar det som förväntat. hth.
Redigera
Naturligtvis kan man släppa $match
steg och lägg till en $out
steg i aggregeringsfasen för att få resultaten förbehandlade:
db.searchtst.aggregate(
{
$group:{
_id:"$_id.word",
occurences:{ $push:{doc:"$_id.doc",field:"$_id.field"}},
score:{$sum:"$value"}
}
},{
$out:"search"
})
Nu kan vi fråga den resulterande search
insamling för att påskynda saker och ting. I princip byter du realtidsresultat mot hastighet.
Redigera 2 :Om förbearbetningsmetoden används, searchtst
samlingen av exemplet bör raderas efter att aggregeringen är klar för att spara både diskutrymme och – ännu viktigare – värdefullt RAM-minne.