Skip to content

Implementing a Google-like search engine using ASP.NET Core and Elasticsearch NEST 5.x (Part 3/4)

Now that we’ve indexed some data, we should have a way of actually searching it.

For this purpose, we’re going to implement the SearchService class, which will be responsible for building and sending our queries to Elastic through the ElasticClient we’re going to provide.

These are the steps we’re going to follow:

  1. Create the SearchService class.
  2. Create a SearchResult object that will serve as a template for the Elastic response.
  3. Create a Search method that accepts a query string and pagination info.
  4. Build a fluent DSL expression that’s going to send a QueryStringQuery to Elastic and return a response.
  5. Map the response to SearchResult and return it.
  6. Expose the Search method through our API.
  7. Register the SearchService as an injectable.
  8. Test if we’re getting correct results by sending a request to our API
Step 1. Creating the SearchService class

In your Elastic folder, add a new class called SearchService with the following implementation:

public class SearchService
{
    public SearchService(ElasticClientProvider clientProvider)
    {
        this.client = clientProvider.Client;
    }

    private readonly ElasticClient client;
}

As you can see, we’re providing an ElasticClient through the previously implemented ElasticClientProvider.

Step 2. Creating the SearchResult class

In your Models folder, add a class called SearchResult with the following implementation:

using System.Collections.Generic;

public class SearchResult<T>
{
    public long Total { get; set; }

    public int Page { get; set; }

    public int PageSize { get; set; }

    public IEnumerable<T> Results { get; set; }

    public long ElapsedMilliseconds { get; set; }
}

This is the object we’re going to return from our Search method. Now let’s create it.

Step 3. Creating the Search method

In your SearchService class, add the following method.

public async Task<SearchResult<Recipe>> Search(string query, int page, int pageSize) {}

This is the method that’s going to be responsible for searching the data.

Step 4. Building our fluent DSL expression

Before starting to write the actual fluent DSL query, we need to think about what we want it to do.

I’ll remind that we want to process the user input in the following way:

  • Exact phrases to look for are marked with quotes (“an example phrase“).
  • Each word/phrase that isn’t marked in any way (example) will be present in the result set’s ingredients.
  • Each word/phrase that is marked with a minus (-example) will not be present in the result set’s ingredients.
  • Each word/phrase can be [boosted][qsboosting] to become more relevant (score higher) in the search results (example^2).

So, if we want to look for recipes containing garlic, tomatoes, chopped onions and not containing eggs and red-pepper flakes, we’ll have to input the query string:

garlic tomatoes -egg* “chopped onions” -“red-pepper flakes” // Order doesn’t matter

All of the above is natively supported by Elastic’s query_string query, which looks like this:

{
  "from": (page - 1) * pageSize,
  "size": pageSize,
  "query": {
    "bool": {
      "must": [
        {
          "query_string": {
            "query": "our user inputted query"
          }
        }
      ]
    }
  }
}

In order to build a similar query with NEST, we’ll use fluent DSL.

This is how our fluent DSL expression should look like.

var response = await this.client.SearchAsync<Recipe>(searchDescriptor => searchDescriptor
                    .Query(queryContainerDescriptor => queryContainerDescriptor
                        .Bool(queryDescriptor => queryDescriptor
                            .Must(queryStringQuery => queryStringQuery
                                .QueryString(queryString => queryString
                                    .Query(query))))) 
                                        .From((page - 1) * pageSize)
                                        .Size(pageSize));

Don’t worry, once we go through it line by line, it will seem a lot less scary!

var response = await this.client.SearchAsync<Recipe>(searchDescriptor => searchDescriptor

We define a variable called response that will hold the response returned by Elastic. We then call the ElasticClient method SearchAsync and initialize a SearchDescriptor object, which will be responsible for describing our query to Elastic.

.Query(queryContainerDescriptor => queryContainerDescriptor

We specifiy that we’ll be sending a query and we initialize a QueryContainerDescriptor object.

.Bool(queryDescriptor => queryDescriptor

We tell the QueryContainerDescriptor object that we’ll be sending a BooleanQuery and we initialize a BooleanQueryDescriptor object.

.Must(queryStringQuery => queryStringQuery

We tell the BooleanQueryDescriptor object that it must match a query we are about to initialize.

.QueryString(queryString => queryString

We specify that the query the BooleanQueryDescriptor must match is of type QueryStringQuery and we initialize it.

.Query(query))))) 

We pass our user inputted query to the QueryStringQuery we initialized.

.From((page - 1) * pageSize)
.Size(pageSize));

Take just the documents the user requested according to the pagination info.

Step 5. Map the response to SearchResult and return it
return new SearchResult<Recipe>
{
    Total = response.Total,
    ElapsedMilliseconds = response.Took,
    Page = page,
    PageSize = pageSize,
    Results = response.Documents
};

This is the actual response that our API will return in the form of JSON.

The implementation of our Search method is completed. Now we just have to expose it through our API, register SearchService as an injectable and we’ll be ready to search some data!

6. Exposing the Search method through our API

This is the simplest step. What we need to do is:

  1. Create a controller responsible for handling the Recipes.
  2. Create a Search action that will accept the user inputted query and pass it to our service.
1. Create the controller

In your project’s Controllers folder, create a new class called RecipeController with the following implementation.

[Route("api/[controller]")]
public class RecipeController : ApiController
{
    public RecipeController(SearchService searchService)
    {
        this.searchService = searchService;
    }

    private readonly SearchService searchService;
}

We’re simply injecting the SearchService and storing it in a local variable.

2. Create the Search action

Now, add a method called search with the following template:

[HttpGet("search")]
public async Task<JsonResult> Search([FromQuery]string query, int page = 1, int pageSize = 10)
{
    var result = await this.searchService.Search(query, page, pageSize);
    return Json(result);
}

Quite simple. The HttpGet attribute specifies that this action will only fire for GET requests, and the FromQuery attribute specifies that the query parameter will be taken from the query string.

We’re just one step away from being able to Search our data.

Step 7. Register the SearchService as an injectable

In your Startup.cs file, in the ConfigureServices method, add the following line:

services.AddTransient(typeof(SearchService));

This will make our SearchService available for injection.


Testing the Search functionality

Okay! We’ve implemented the Search function and exposed an endpoint through our API. Let’s search some recipes!

Fire up Postman and make a request to:

http://yourappurl/api/recipe/search?query=whatever query you wish

First search result Postman

Awesome, isn’t it? With just a few lines of code we managed to create a search engine that’s able to handle complicated user input.

Don’t miss part 4, when we’ll be implementing the Autocomplete and More like this functionality!

As always, if you have any questions, don’t hesitate to contact me!

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *