Depois de pensar muito nisso, acho que é possível implementar o que você deseja. No entanto, não é adequado para bancos de dados muito grandes e ainda não desenvolvi uma abordagem incremental. Falta stemming e palavras de parada devem ser definidas manualmente.
A ideia é usar mapReduce para criar uma coleção de palavras de pesquisa com referências ao documento de origem e ao campo de onde a palavra de pesquisa se originou. Então, para a consulta real, o preenchimento automático é feito usando uma agregação simples que utiliza um índice e, portanto, deve ser bastante rápida.
Então vamos trabalhar com os três documentos a seguir
{
"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"
}
em uma coleção chamada
textsearch
. O estágio de mapear/reduzir
O que basicamente fazemos é processar cada palavra em um dos três campos, remover palavras de parada e números e salvar cada palavra com o
_id
do documento e o campo da ocorrência em uma tabela intermediária. O código anotado:
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" }
)
Executar isso resultará na criação da coleção
searchtst
. Se já existia, todo o seu conteúdo será substituído. Vai parecer algo assim:
{ "_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 }
[...]
Há algumas coisas a serem observadas aqui. Em primeiro lugar, uma palavra pode ter várias ocorrências, por exemplo com "FL". No entanto, pode estar em documentos diferentes, como é o caso aqui. Por outro lado, uma palavra também pode ter várias ocorrências em um único campo de um único documento. Usaremos isso a nosso favor mais tarde.
Segundo, temos todos os campos, principalmente a
word
campo em um índice composto para _id
, o que deve tornar as próximas consultas bem rápidas. No entanto, isso também significa que o índice será bastante grande e – como para todos os índices – tende a consumir RAM. O estágio de agregação
Assim, reduzimos a lista de palavras. Agora consultamos uma (sub)string. O que precisamos fazer é encontrar todas as palavras que começam com a string que o usuário digitou até agora, retornando uma lista de palavras que correspondem a essa string. Para poder fazer isso e obter os resultados de uma forma adequada para nós, usamos uma agregação.
Essa agregação deve ser bem rápida, pois todos os campos necessários para consulta fazem parte de um índice composto.
Aqui está a agregação anotada para o caso em que o usuário digitou a letra
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}
}
)
O resultado dessa consulta se parece com isso e deve ser bastante autoexplicativo:
{
"_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
}
A pontuação de 5 para Sushi vem do fato de a palavra Sushi ocorrer duas vezes no campo de notas de um dos documentos. Este é o comportamento pretendido.
Embora esta possa ser uma solução para pobres, precisa ser otimizada para a miríade de casos de uso pensáveis e precisaria de um mapReduce incremental para ser implementado para ser útil em ambientes de produção, funciona conforme o esperado. hth.
Editar
Claro, pode-se descartar o
$match
stage e adicione um $out
etapa na fase de agregação para que os resultados sejam pré-processados:db.searchtst.aggregate(
{
$group:{
_id:"$_id.word",
occurences:{ $push:{doc:"$_id.doc",field:"$_id.field"}},
score:{$sum:"$value"}
}
},{
$out:"search"
})
Agora, podemos consultar a
search
resultante coleta para agilizar as coisas. Basicamente, você troca resultados em tempo real por velocidade. Editar 2 :Caso a abordagem de pré-processamento seja adotada, o
searchtst
A coleção do exemplo deve ser excluída após a conclusão da agregação para economizar espaço em disco e – mais importante – RAM preciosa.