skies.dev

How to Sort by Geo Distance Using Bounding Boxes in Algolia

5 min read

Under the hood of an app I'm helping build, we utilize Algolia for search results. One of the features we support are to show items outside the user's search radius.

For example, let's say you were looking for an item to buy within 30 miles of you. But it so happens that the perfect item is listed 31 miles from you. In this case, we'll show you a module with items outside your search radius (e.g. 30-50 miles).

We'll define the problem concretely.

Let radius be the radius from the user to the item. Let min_radius and max_radius be the minimum/maximum radius to search within. We need to search for items where min_radius < radius < max_radius.

Limitations of Algolia Geo Filters

As of the time of writing, Algolia has no native way to solve such a problem.

The only way to get geo-sorting is by using their aroundRadius parameter, but this param doesn't support a min_radius param.

We also considered using Algolia's insideBoundingBox parameter, which lets us specify multiple boxes to search within. We could support the area where min_radius < radius < max_radius by constructing 4 boxes around the inner bounding box. However,

aroundLatLng will be ignored if used along with this parameter.

This means we wouldn't be able to support our "sort by closest items first" use case. This is a non-starter.

We could've worked around this limitation by changing the client. But being that our business is centered around our mobile app, the client code is already deployed across many customers' devices.

We need to be able to make this change server-side to stay nimble.

Using Numeric Filters to Create Bounding Boxes

To support geo-sorting, Algolia expects documents to contain a _geoloc property specifying the coordinates.

For example, an item document would contain something like the following:

{
  "_geoloc": {
    "lat": 47.6062,
    "lng": -122.3321
  }
}

With this available, we're able to configure the index to allow _geoloc.lat and _geoloc.lng to be used as numeric filters to create our own filtering logic.

To sort by distance, we can combine aroundLatLng and aroundRadius. The radius we define will surround the bounding boxes we create using numeric filters on _geoloc.lat and _geoloc.lng.

Implementation Details

Let's recall the problem: We want to search for items where the item's radius is between some min_radius and max_radius.

Here's how we can solve it:

  • Set aroundLatLng to the user's location.
  • Set aroundRadius to "all". We need the radius to surround the bounding boxes we create.
  • Create bounding boxes by filtering on _geoloc.lat and _geoloc.lng.

Let's see what this looks like in practice.

We'll show all items within a bounding box. Let's assume you've done some logic to compute the coordinates of the upper-left and bottom-right points of the bounding box.

index.search('query', {
  aroundRadius: 'all',
  aroundLatLng: '47.6062, -122.3321',
  filters: [
    `_geoloc.lat < ${upperLeft.lat}`,
    `_geoloc.lat > ${bottomRight.lat}`,
    `_geoloc.lng > ${upperLeft.lng}`,
    `_geoloc.lng < ${bottomRight.lng}`,
  ].join(' AND '),
});

This is a start.

Now we want to filter out the items that are contained within an inner bounding box.

In other words, let's call the outer bounding box withinOuterBoundingBox and the inner bounding box withinInnerBoundingBox. We want to search the items that satisfy

withinOuterBoundingBox && !withinInnerBoundingBox;

We already saw above how to search within the outer bounding box.

const withinOuterBoundingBox = [
  `_geoloc.lat < ${upperLeft.lat}`,
  `_geoloc.lat > ${bottomRight.lat}`,
  `_geoloc.lng > ${upperLeft.lng}`,
  `_geoloc.lng < ${bottomRight.lng}`,
].join(' AND ');

You might think we could negate a similar filter for the inner bounding box:

NOT (A AND B AND C AND D)

However, Algolia doesn't let us create a filter like this.

You can verify filter syntax with Algolia's filter syntax validator.

We can workaround this limitation by distributing the NOT using De Morgan's Law. The following is logically equivalent and valid filter syntax:

NOT A OR NOT B OR NOT C OR NOT D

We wouldn't use the NOT syntax since we're dealing with numeric filters.

In order to negate an inequality, we need to flip the sign.

  • < becomes >=
  • > becomes <=

We can now piece together an expression to exclude the inner bounding box.

const notWithinInnerBoundingBox = [
  `_geoloc.lat >= ${innerBoxUpperLeft.lat}`,
  `_geoloc.lat <= ${innerBoxBottomRight.lat}`,
  `_geoloc.lng <= ${innerBoxUpperLeft.lng}`,
  `_geoloc.lng >= ${innerBoxBottomRight.lng}`,
].join(' OR ');

Putting this all together, our Algolia query becomes:

index.search('query', {
  aroundRadius: 'all',
  aroundLatLng: '47.6062, -122.3321',
  filters: `${withinOuterBoundingBox} AND ${notWithinInnerBoundingBox}`,
});

And that's how we solved this problem. ๐ŸŽ‰

Bonus: Performance Considerations of Setting aroundRadius to "all"

I was concerned that setting aroundRadius to "all" would degrade performance.

We did some performance tests comparing the results of setting aroundRadius to "all" versus the minimum radius that surrounds the outer bounding box.

It didn't make much of a difference.

To keep things simple, you're probably fine setting aroundRadius to "all" when using _geoloc to filter results.

Hey, you! ๐Ÿซต

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like