Conversational search with Algolia & Google Dialogflow

June 21, 2018 0 Comments

Conversational search with Algolia & Google Dialogflow

 

 


Search isn't just the domain of the white box in the top right corner any more. With more and more people feeling comfortable talking to their either phone, Alexa, or Google Assistant, the need for apps to support search that is directed conversationally increases day by day.

Fortunately for us developers, this increased need brings a plethora of new APIs and tools to speed the process of shifting from the top right corner to the messaging app, or handy assistant device of your choice.

One of the tools I've found to get you up and running quickly is Dialogflow by Google. Formerly known as API.ai, this service allows you to build, train, and even write code for a conversational interface. Better still, it'll even help you launch your newly crafted 'bot' on a multitude of services, including Slack, Facebook Messenger and Telegram.

Let's get started

If you're going to try this for yourself, you'll need the following:


  • An Algolia account (they start at free), with an index of data that you'd like to search

  • A Dialogflow account. You'll need to log in using a Google account.

The logic behind our search, and what results get returned is going to be handled by a single webhook endpoint that Dialogflow will POST every outbound request to. In this example, this webhook will be a script, written in NodeJS, but hosted as a Google Cloud Function.

If you don't want to get into working with GCF just yet, then please note that the code here will also work as a normal NodeJS app that you can host wherever you like, as long as it's still publicly accessible.

The Algolia Index

We'll be using Algolia as our hosted search platform of choice for this example. You can choose whatever data you like to populate your index, but consider that this is something people will be searching for in a conversational/bot UX.

I'm using an index of the group listings for the upcoming FIFA World Cup in Russia that was also the basis of this great World Cup Search app by my colleague Sarah Dayan.

Here's an example of what data we have to play with:

An Algolia index object

It's a pretty rich data set with lots of fields that will lend themselves to a conversational UX style. Just by looking at this you can already see some of the questions we might want to consider handling:


  • "When do Argentina play Croatia?"

  • "What group are Croatia in?"

  • "Which TV channel will show the Croatia and Argentina game?"

  • "Where are Argentia and Croatia playing?"

Let's start translating that into queries we can use.

Setting up Dialogflow

Once you have created your Dialogflow account, you'll need to make sure two things are taken care of.


  • Making a decision about how and where your bot is going to return responses from

  • Training the bot to understand and pattern match various queries

In this example we're going to be returning our data from an Algolia index, so we'll need to use a webhook.

Webhook fulfilment settings

Your webhook can be a URL for any service that will accept a POST request and then return some data. You could write a small HTTP server using Node, Ruby, Go or any language that suits your preference. Just make sure that the endpoint you're using is publicly available.

I chose to use Google Cloud Functions to build this app. GCF are 'serverless' scripts that are hosted by Google and can be deployed and updated using their GCloud command line interface.

My function has one endpoint: performSearch, and the URL that it was automatically assigned on deployment to GCF is: https://us-central1-buoyant-history-206309.cloudfunctions.net/performSearch.

This is the URL I used for the webhook set up, as you can see in the image above. Your URL will be different.

Once the webhook is enabled, all interaction with your Dialogflow app will be POSTed to it and you can handle the responses from there.

Training

Context is incredibly important when designing conversational interfaces and training is a big part of helping your app understand what the user is really asking.

The bot in action

Above you can see the user asking several questions about the group stages of the World Cup. We need our bot to understand what kind of questions will relate to this context, or in Dialogflow speak, this 'intent'.

Before an intent can extract the information you need to make decisions on the server side, you need to give it plenty of information to digest.

Here is one of the two pages of training phrases I gave the system so it could learn about 'groups':

Training phrases for groups

Dialogflow will attempt to match certain data points to what you typed in. You can see different colours highlighting certain words in the image above. This happens when that word is matched to any one of the actions and/or parameters you have set up

Actions and Parameters

For this particular data set, and to work with groups, we need to be able to identify three things:


  1. That the intent is to talk about 'groups'

  2. A country

  3. A group letter (a, b, c, d etc)

Alternatively, you can look at it in terms of programming. For our function to work we need:


  1. A way to determine which function to use to return data

  2. A country to pass to Algolia as a search parameter

  3. A group letter to pass to Algolia as a search parameter

Once you've loaded up the training phrases you can use the Dialogflow emulator (which is on every page) to test out if you're getting matches on the parameters you need:

Dialogflow Emulator

You aren't going to get any responses back because you haven't written any code yet, but you will be able to see if you're getting matches.

Getting matches!

Handling the POST requests

Once you're getting decent matches you can start working with the data that will be POSTed to your webhook.

To handle the requests to Algolia that concern data for Groups, our function looks like this:

exports.performSearch = (req, res) => { let intent = req.body.queryResult.intent.displayName; let params = req.body.queryResult.parameters; switch (intent) { case 'group': groups(params); break; default: res.json({ fulfillmentText: "Sorry, I don\'t understand what you're asking me about" }); } function groups(intentParams) { if (.isEmpty(intentParams['geo-country'])) { const groupName = intentParams.groupname.includes('?') ? intentParams.groupname.split('?')[0] : intentParams.groupname; index.search({ query: Group </span><span class="p">${</span><span class="nx">groupName</span><span class="p">}</span><span class="s2"> }, (err, data) => { const teams = data.hits.map(hit => hit.hometeam); const teamsUnique = [...new Set(teams)]; res.json({ fulfillmentText: The teams in Group </span><span class="p">${</span><span class="nx">groupName</span><span class="p">.</span><span class="nx">toUpperCase</span><span class="p">()}</span><span class="s2"> are </span><span class="p">${</span><span class="nx">teamsUnique</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span> <span class="s1">&apos;, &apos;</span> <span class="p">)}</span><span class="s2"> }); }); } else { const country = intentParams['geo-country']; index.search({ query: country }, (err, data) => { res.json({ fulfillmentText: </span><span class="p">${</span><span class="nx">country</span><span class="p">}</span><span class="s2"> are in </span><span class="p">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">hits</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">name</span><span class="p">}</span><span class="s2"> }); }); } } 
}

There's quite a bit of clean up and formatting code here because I wanted the results returned to the user to be more 'human', or at least more human readable.

Let's break down the code shown above:

We grab the incoming intent and associated params from the POST request:

let intent = req.body.queryResult.intent.displayName; 
let params = req.body.queryResult.parameters;

Google Cloud Functions actually uses Express for the HTTP portion so if you have used that before, it will feel familiar.

Using the intent we decide which function will handle the data and return the response:

switch (intent) { case 'group': groups(params); break; // Insert further case statements for other intents here default: res.json({ fulfillmentText: "Sorry, I dont understand what you're asking me about" }); 
}

Once we have a function to use, we need to determine if the user is asking about a group letter in general, or wants to know what group a specific team is in. We do that by checking for the presence of a value in the geo-country parameter using Lodash's isEmpty method:

if (.isEmpty(intentParams['geo-country'])) { /* No country present so get all the teams in the group/ 
} else { /
A country has been mentioned, get what group they are in */
}

Then we search our Algolia index. Because Algolia has excellent matching within text strings, we can just set our query to be 'Group B' and we'll get everything back that matches that. Pretty nifty.

index.search({ query: Group </span><span class="p">${</span><span class="nx">groupName</span><span class="p">}</span><span class="s2"> }, (err, data) => { const teams = data.hits.map(hit => hit.hometeam); const teamsUnique = [...new Set(teams)]; res.json({ fulfillmentText: The teams in Group </span><span class="p">${</span><span class="nx">groupName</span><span class="p">.</span><span class="nx">toUpperCase</span><span class="p">()}</span><span class="s2"> are </span><span class="p">${</span><span class="nx">teamsUnique</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span> <span class="s1">&apos;, &apos;</span> <span class="p">)}</span><span class="s2"> }); 
});

Our query will return everything that matches a record containing the group letter we're searching for plus every other field. We don't need all that, so we use the map high order array method to make a new array of just the team names:

const teams = data.hits.map(hit => hit.hometeam); 

We also want to make sure that array contains only unique team names. There are a few different ways to do this but the one I used here was new to me, using the spread operator to parse a unique Set:

// const teams = ['France', 'Spain', 'Argentina', 'Argentina'] 
const teamsUnique = [...new Set(teams)];

Once the array has been uniquified it's time to return it to Dialogflow in the same way you would with any Express/Node app: as a JSON object:

res.json({ fulfillmentText: The teams in Group </span><span class="p">${</span><span class="nx">groupName</span><span class="p">.</span><span class="nx">toUpperCase</span><span class="p">()}</span><span class="s2"> are </span><span class="p">${</span><span class="nx">teamsUnique</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="s1">&apos;, &apos;</span><span class="p">)}</span><span class="s2"> 
});

Dialogflow expects an object with at least a key of fulfillmentText. The value of this can be any text string or number you like. Above, we flatten the array of teams out to a single string and pass that back.

You can see that in action right here:

Group responses

We also need to handle a user asking what group a country is in. Dialogflow detects the presence of countries in text automatically and passes them along as a parameter called geo-country. At the start of our function we check to see if this is present or not and if it is we perform a slightly different search in Algolia:

const country = intentParams['geo-country']; index.search({ query: country }, (err, data) => { res.json({ fulfillmentText: </span><span class="p">${</span><span class="nx">country</span><span class="p">}</span><span class="s2"> are in </span><span class="p">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">hits</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">name</span><span class="p">}</span><span class="s2"> }); 
});

Again, the bonus of Algolia's text search capability means we can be quite loose with how we structure the query. In this instance, we can just pass along the country name and it will work exactly like this:

Text search in Agolia

Testing

Dialogflow allows you to use lots of different interfaces to interact with the same bot. You can find all of them in the Integrations tab on the dashboard and use any of them for testing purposes.

The quickest is the Web Demo. This provides you with a live URL of a page with a chat window on it that you can use to test out your queries.

Handling edge cases using Algolia

Building out this example provided a great edge case that Algolia was able to help me solve in a matter of seconds.

Dialogflow is really good at identifying when you've written about a country, but what gets passed over in the params of the POST request it sends out is the official name of a country.

Example:

User input: "Iran"

Dialogflow output: "Islamic Republic of Iran"

If you search the index for the Islamic Republic of Iran, you're not going to get anything back. The same goes for Russia because their official name is Russian Federation.

You could code around this, but that's not really the right approach and can get really out of hand if you have a lot of this type of mismatch.

To solve this, I used the Query Expansion feature that Algolia provides for each index that allows you to specify a set of synonyms that the index should consider to be the same thing. Basically allowing me to say 'If you see the Islamic Republic of Iran then what I really mean is Iran'.

Synonyms in Algolia

Use of this feature whilst training your bot will allow you get very accurate results, very quickly, with lower code overhead.

Go forth and search conversationally

I've barely skimmed the surface of what is possible when you combine Dialogflow with Algolia but it's easy to see how using the two in unison could help build some powerful experiences.

If you already have an index in Algolia, try building a simple conversational interface for it. As an alternative to using the Webhook method we've used here, Dialogflow allows you to write Firebase Functions directly in the user interface which, if nothing else, will allow you to experiment and get up and running very quickly.


Tag cloud