Search code examples
c#elasticsearchnest

Elastic Search MoreLikeThis Query Never Returns Results


I must be doing something fundamentally wrong here. I'm trying to get a "More Like This" query working in a search engine project we have that uses Elastic Search. The idea is that the CMS can write tags (like categories) to the page in a Meta tag or something, and we would read those into Elastic and use them to drive a "more like this" search based upon an input document id.

So if the input document has tags of catfish, chicken, goat I would expect Elastic Search to find other documents that share those tags and not return ones for racecar and airplane.

I've built a proof of concept console app by:

Then I created a new elastic index that contains objects (Type "MyThing") that have a "Tags" property. This tag is a random comma-delimited set of words from a set of possible values. I've inserted anywhere from 100 to 5000 items in the index in testing. I've tried more and fewer possible words in the set.

No matter what I try the MoreLikeThis query never returns anything, and I don't understand why.

Query that isn't returning results:

    var result = EsClient.Search<MyThing>(s => s
        .Index(DEFAULT_INDEX)
        .Query(esQuery =>
        {
            var mainQuery = esQuery
                .MoreLikeThis(mlt => mlt
                    .Include(true)
                    .Fields(f => f.Field(ff => ff.Tags, 5))
                    .Like(l => l.Document(d => d.Id(id)))
                );

            return mainQuery;
        }

Full "program.cs" source:

using Nest;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Test_MoreLikeThis_ES6
{
    class Program
    {
        public class MyThing
        {
            public string Tags { get; set; }
        }

        const string ELASTIC_SERVER = "http://localhost:9200";
        const string DEFAULT_INDEX = "my_index";
        const int NUM_RECORDS = 1000;

        private static Uri es_node = new Uri(ELASTIC_SERVER);
        private static ConnectionSettings settings = new ConnectionSettings(es_node).DefaultIndex(DEFAULT_INDEX);
        private static ElasticClient EsClient = new ElasticClient(settings);

        private static Random rnd = new Random();

        static void Main(string[] args)
        {
            Console.WriteLine("Rebuild index? (y):");
            var answer = Console.ReadLine().ToLower();
            if (answer == "y")
            {
                RebuildIndex();
                for (int i = 0; i < NUM_RECORDS; i++)
                {
                    AddToIndex();
                }
            }

            Console.WriteLine("");
            Console.WriteLine("Getting a Thing...");
            var aThingId = GetARandomThingId();


            Console.WriteLine("");
            Console.WriteLine("Looking for something similar to document with id " + aThingId);
            Console.WriteLine("");
            Console.WriteLine("");

            GetMoreLikeAThing(aThingId);
        }

        private static string GetARandomThingId()
        {
            var firstdocQuery = EsClient
                .Search<MyThing>(s =>
                    s.Size(1)
                    .Query(q => {
                        return q.FunctionScore(fs => fs.Functions(fn => fn.RandomScore(rs => rs.Seed(DateTime.Now.Ticks).Field("_seq_no"))));
                    })
                );

            if (!firstdocQuery.IsValid || firstdocQuery.Hits.Count == 0) return null;

            var hit = firstdocQuery.Hits.First();
            Console.WriteLine("Found a thing with id '" + hit.Id + "' and tags: " + hit.Source.Tags);
            return hit.Id;
        }

        private static void GetMoreLikeAThing(string id)
        {

            var result = EsClient.Search<MyThing>(s => s
                .Index(DEFAULT_INDEX)
                .Query(esQuery =>
                {
                    var mainQuery = esQuery
                        .MoreLikeThis(mlt => mlt
                            .Include(true)
                            .Fields(f => f.Field(ff => ff.Tags, 5))
                            .Like(l => l.Document(d => d.Id(id)))
                        );

                    return mainQuery;
                }

            ));

            if (result.IsValid)
            {
                if (result.Hits.Count > 0)
                {
                    Console.WriteLine("These things are similar:");
                    foreach (var hit in result.Hits)
                    {
                        Console.WriteLine("   " + hit.Id + " : " + hit.Source.Tags);
                    }
                }
                else
                {
                    Console.WriteLine("No similar things found.");
                }

            }
            else
            {
                Console.WriteLine("There was an error running the ES query.");
            }

            Console.WriteLine("");
            Console.WriteLine("Enter (y) to get another thing, or anything else to exit");
            var y = Console.ReadLine().ToLower();

            if (y == "y")
            {
                var aThingId = GetARandomThingId();
                GetMoreLikeAThing(aThingId);
            }

            Console.WriteLine("");
            Console.WriteLine("Any key to exit...");
            Console.ReadKey();

        }

        private static void RebuildIndex()
        {
            var existsResponse = EsClient.IndexExists(DEFAULT_INDEX);
            if (existsResponse.Exists) //delete existing mapping (and data)
            {
                EsClient.DeleteIndex(DEFAULT_INDEX);
            }

            var rebuildResponse = EsClient.CreateIndex(DEFAULT_INDEX, c => c.Settings(s => s.NumberOfReplicas(1).NumberOfShards(5)));
            var response2 = EsClient.Map<MyThing>(m => m.AutoMap());
        }

        private static void AddToIndex()
        {
            var myThing = new MyThing();
            var tags = new List<string> {
                    "catfish",
                    "tractor",
                    "racecar",
                    "airplane",
                    "chicken",
                    "goat",
                    "pig",
                    "horse",
                    "goose",
                    "duck"
                };

            var randNum = rnd.Next(0, tags.Count);

            //get randNum random tags
            var rand = tags.OrderBy(o => Guid.NewGuid().ToString()).Take(randNum);
            myThing.Tags = string.Join(", ", rand);

            var ir = new IndexRequest<MyThing>(myThing);
            var indexResponse = EsClient.Index(ir);

            Console.WriteLine("Index response: " + indexResponse.Id + " : " + string.Join(" " , myThing.Tags));
        }
    }
}

Solution

  • The issue here is that the default min_term_freq value of 2 will never be satisfied for any of the terms of the prototype document because all documents contain only each tag (term) once. If you drop min_term_freq to 1, you'll get results. Might also want to set min_doc_freq to 1 too, and combine with a query that excludes the prototype document.

    Here's an example to play with

    const string ELASTIC_SERVER = "http://localhost:9200";
    const string DEFAULT_INDEX = "my_index";
    const int NUM_RECORDS = 1000;
    
    private static readonly Random _random = new Random();
    private static readonly IReadOnlyList<string> Tags = 
        new List<string>
        {
            "catfish",
            "tractor",
            "racecar",
            "airplane",
            "chicken",
            "goat",
            "pig",
            "horse",
            "goose",
            "duck"
        };
    
    private static ElasticClient _client;
    
    private static void Main()
    {
        var pool = new SingleNodeConnectionPool(new Uri(ELASTIC_SERVER));
    
        var settings = new ConnectionSettings(pool)
            .DefaultIndex(DEFAULT_INDEX);
    
        _client = new ElasticClient(settings);
    
        Console.WriteLine("Rebuild index? (y):");
        var answer = Console.ReadLine().ToLower();
        if (answer == "y")
        {
            RebuildIndex();
            AddToIndex();
        }
    
        Console.WriteLine();
        Console.WriteLine("Getting a Thing...");
        var aThingId = GetARandomThingId();
    
        Console.WriteLine();
        Console.WriteLine("Looking for something similar to document with id " + aThingId);
        Console.WriteLine();
        Console.WriteLine();
    
        GetMoreLikeAThing(aThingId);
    }
    
    public class MyThing
    {
        public List<string> Tags { get; set; }
    }
    
    private static string GetARandomThingId()
    {
        var firstdocQuery = _client
            .Search<MyThing>(s =>
                s.Size(1)
                .Query(q => q
                    .FunctionScore(fs => fs
                        .Functions(fn => fn
                            .RandomScore(rs => rs
                                .Seed(DateTime.Now.Ticks)
                                .Field("_seq_no")
                            )
                        )
                    )
                )
            );
    
        if (!firstdocQuery.IsValid || firstdocQuery.Hits.Count == 0) return null;
    
        var hit = firstdocQuery.Hits.First();
        Console.WriteLine($"Found a thing with id '{hit.Id}' and tags: {string.Join(", ", hit.Source.Tags)}");
        return hit.Id;
    }
    
    private static void GetMoreLikeAThing(string id)
    {
        var result = _client.Search<MyThing>(s => s
            .Index(DEFAULT_INDEX)
            .Query(esQuery => esQuery 
                .MoreLikeThis(mlt => mlt
                        .Include(true)
                        .Fields(f => f.Field(ff => ff.Tags))
                        .Like(l => l.Document(d => d.Id(id)))
                        .MinTermFrequency(1)
                        .MinDocumentFrequency(1)
                ) && !esQuery
                .Ids(ids => ids
                    .Values(id)
                )
            )
        );
    
        if (result.IsValid)
        {
            if (result.Hits.Count > 0)
            {
                Console.WriteLine("These things are similar:");
                foreach (var hit in result.Hits)
                {
                    Console.WriteLine($"   {hit.Id}: {string.Join(", ", hit.Source.Tags)}");
                }
            }
            else
            {
                Console.WriteLine("No similar things found.");
            }
    
        }
        else
        {
            Console.WriteLine("There was an error running the ES query.");
        }
    
        Console.WriteLine();
        Console.WriteLine("Enter (y) to get another thing, or anything else to exit");
        var y = Console.ReadLine().ToLower();
    
        if (y == "y")
        {
            var aThingId = GetARandomThingId();
            GetMoreLikeAThing(aThingId);
        }
    
        Console.WriteLine();
        Console.WriteLine("Any key to exit...");
    
    }
    
    private static void RebuildIndex()
    {
        var existsResponse = _client.IndexExists(DEFAULT_INDEX);
        if (existsResponse.Exists) //delete existing mapping (and data)
        {
            _client.DeleteIndex(DEFAULT_INDEX);
        }
    
        var rebuildResponse = _client.CreateIndex(DEFAULT_INDEX, c => c
            .Settings(s => s
                .NumberOfShards(1)
            )
            .Mappings(m => m       
                .Map<MyThing>(mm => mm.AutoMap())
            )
        );
    }
    
    private static void AddToIndex()
    {
        var bulkAllObservable = _client.BulkAll(GetMyThings(), b => b
            .RefreshOnCompleted()
            .Size(1000));
    
        var waitHandle = new ManualResetEvent(false);
        Exception exception = null;
    
        var bulkAllObserver = new BulkAllObserver(
            onNext: r =>
            {
                Console.WriteLine($"Indexed page {r.Page}");
            },
            onError: e => 
            {
                exception = e;
                waitHandle.Set();
            },
            onCompleted: () => waitHandle.Set());
    
        bulkAllObservable.Subscribe(bulkAllObserver);
    
        waitHandle.WaitOne();
    
        if (exception != null)
        {
            throw exception;
        }
    }
    
    private static IEnumerable<MyThing> GetMyThings()
    {
        for (int i = 0; i < NUM_RECORDS; i++)
        {
            var randomTags = Tags.OrderBy(o => Guid.NewGuid().ToString())
                .Take(_random.Next(0, Tags.Count))
                .OrderBy(t => t)
                .ToList();
    
            yield return new MyThing { Tags = randomTags };
        }
    }
    

    And here's an example output

    Found a thing with id 'Ugg9LGkBPK3n91HQD1d5' and tags: airplane, goat
    These things are similar:
       4wg9LGkBPK3n91HQD1l5: airplane, goat
       9Ag9LGkBPK3n91HQD1l5: airplane, goat
       Vgg9LGkBPK3n91HQD1d5: airplane, goat, goose
       sQg9LGkBPK3n91HQD1d5: airplane, duck, goat
       lQg9LGkBPK3n91HQD1h5: airplane, catfish, goat
       9gg9LGkBPK3n91HQD1l5: airplane, catfish, goat
       FQg9LGkBPK3n91HQD1p5: airplane, goat, goose
       Jwg9LGkBPK3n91HQD1p5: airplane, goat, goose
       Fwg9LGkBPK3n91HQD1d5: airplane, duck, goat, tractor
       Kwg9LGkBPK3n91HQD1d5: airplane, goat, goose, horse