Dopo averci pensato a lungo, penso che sia possibile realizzare ciò che vuoi. Tuttavia, non è adatto per database molto grandi e non ho ancora elaborato un approccio incrementale. Manca di stemming e le parole di stop devono essere definite manualmente.
L'idea è di utilizzare mapReduce per creare una raccolta di parole di ricerca con riferimenti al documento di origine e al campo da cui ha avuto origine la parola di ricerca. Quindi, poiché la query effettiva per il completamento automatico viene eseguita utilizzando una semplice aggregazione che utilizza un indice e quindi dovrebbe essere piuttosto veloce.
Quindi lavoreremo con i seguenti tre documenti
{
"name" : "John F. Kennedy",
"address" : "Kenson Street 1, 12345 Footown, TX, USA",
"note" : "loves Kendo and Sushi"
}
e
{
"name" : "Robert F. Kennedy",
"address" : "High Street 1, 54321 Bartown, FL, USA",
"note" : "loves Ethel and cigars"
}
e
{
"name" : "Robert F. Sushi",
"address" : "Sushi Street 1, 54321 Bartown, FL, USA",
"note" : "loves Sushi and more Sushi"
}
in una raccolta chiamata textsearch
.
La fase mappa/riduzione
Fondamentalmente, elaboreremo ogni singola parola in uno dei tre campi, rimuoveremo le parole e i numeri di arresto e salveremo ogni singola parola con il _id
del documento e il campo dell'occorrenza in una tabella intermedia.
Il codice annotato:
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" }
)
L'esecuzione comporterà la creazione della raccolta searchtst
. Se esisteva già, tutti i suoi contenuti verranno sostituiti.
Sarà simile a questo:
{ "_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 }
[...]
Ci sono alcune cose da notare qui. Innanzitutto, una parola può avere più occorrenze, ad esempio con "FL". Tuttavia, potrebbe trovarsi in documenti diversi, come è il caso qui. Una parola può anche avere più occorrenze in un singolo campo di un singolo documento, d'altra parte. Lo useremo a nostro vantaggio in seguito.
In secondo luogo, abbiamo tutti i campi, in particolare la word
campo in un indice composto per _id
, il che dovrebbe rendere le query in arrivo piuttosto veloci. Tuttavia, questo significa anche che l'indice sarà piuttosto grande e, come per tutti gli indici, tenderà a consumare RAM.
La fase di aggregazione
Quindi abbiamo ridotto l'elenco delle parole. Ora interroghiamo una (sotto)stringa. Quello che dobbiamo fare è trovare tutte le parole che iniziano con la stringa che l'utente ha digitato fino a quel momento, restituendo un elenco di parole che corrispondono a quella stringa. Per poterlo fare e per ottenere i risultati in una forma a noi adatta, utilizziamo un'aggregazione.
Questa aggregazione dovrebbe essere abbastanza veloce, poiché tutti i campi necessari per eseguire query fanno parte di un indice composto.
Ecco l'aggregazione annotata per il caso in cui l'utente ha digitato la lettera 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}
}
)
Il risultato di questa query è simile a questo e dovrebbe essere abbastanza autoesplicativo:
{
"_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
}
Il punteggio di 5 per Sushi deriva dal fatto che la parola Sushi compare due volte nel campo delle note di uno dei documenti. Questo è il comportamento previsto.
Sebbene questa possa essere una soluzione scadente, deve essere ottimizzata per la miriade di casi d'uso pensabili e avrebbe bisogno di un mapReduce incrementale da implementare per essere a metà strada utile negli ambienti di produzione, funziona come previsto. hth.
Modifica
Naturalmente, si potrebbe eliminare il $match
stage e aggiungi un $out
fase nella fase di aggregazione per avere i risultati preelaborati:
db.searchtst.aggregate(
{
$group:{
_id:"$_id.word",
occurences:{ $push:{doc:"$_id.doc",field:"$_id.field"}},
score:{$sum:"$value"}
}
},{
$out:"search"
})
Ora possiamo interrogare la search
risultante raccolta per velocizzare le cose. Fondamentalmente scambi i risultati in tempo reale con la velocità.
Modifica 2 :Nel caso in cui venga adottato l'approccio di preelaborazione, il searchtst
raccolta dell'esempio dovrebbe essere cancellata al termine dell'aggregazione per risparmiare spazio su disco e, cosa più importante, preziosa RAM.