For the project I'm working on, I need to perform a text search on listings within a specific area. In this 3-part article, I want to learn out loud about my process of developing this feature for my project, by discussing the problems I run into and which solutions I'm considering.

Use case

Together with my team of successful dropouts, I'm developing an open marketplace for learning in real life. The idea is that anybody that has something to teach can create a listing and be found by people close to them, so that they can learn from that person directly, in real life.

Because of this, a user needs to be able to search within a certain radius of their location, but also be able to perform a text search to find something specific he or she may want to learn, such as welding, drone flying or self-defense.

We're building the web application using the MEVN stack, which means that we use a reactive Vue app in the frontend that consumes a NodeJS API, built on top of a Mongo database.

Step 1: geoSearch with $geoNear

I decided to build this feature from the ground up, starting with geoSearch: the feature that allows you to search within a certain radius from a specific geographical point.

This part is actually fairly easy with MongoDB, which is a big selling point for this open source document database; and one of my main reasons for using it.

Mongo supports several geoJSON object types, but for our use case we're going to work with points, mostly. The coordinates are listed in an array with the longitude first and then the latitude.

Working with the $geoNear operator requires a geoSpatial index. Creating an index like this is very straightforward using mongoose and makes the Schema look like this:


const geoSchema = new mongoose.Schema({
  type: {
    type: String,
    default: "Point"
  },
  coordinates: {
    type: [Number], 
    index: "2dsphere" //creates the index
  }
})

This geoSchema can then be inserted in the mongoose Schema for the listing itself, under geometry:


const listingSchema = new mongoose.Schema({
  _id: mongoose.Schema.Types.ObjectId,
  title: {type: String, required: true},
  description: {type: String, required: true},
  price: {type: Number, required: true},
  geometry: geoSchema
});

After the Schema is created, the next step is using the $geoNear operator in the route as follows:


Listing.aggregate([
    {
      $geoNear: {
        near: {
          type: 'Point',
          coordinates: coordinates
        },
        distanceField: "dist.calculated",
        maxDistance: parseFloat(req.query.distance),
        query: {
              price: { $lt: pricemax}
        },
        spherical: true
      }
    }
  ])
  

This $geoNear operation will output documents in order of nearest to farthest from the specified point. It also includes the calculated distance to that specified point under the field specified under distanceField.

As you can see, the $geoNear operation also supports adding a query field to limit the results to the documents that match the query. In this case, we're looking for listings where the price is lower than ($lt) the maximum price pricemax that the user specified.

This all works very well, and very quickly and efficiently thanks to the geoSpatial index we're using. So far, so good.

Step 2: full text search with $text

Mongo also provides the opportunity to do a full text search of all the documents in a collection using the $text operator. This operation takes the specified text, parses it and performs an OR query for matches of the stemmed word.

This operation also requires an index on the text fields to be searched (e.g. title and description). Using mongoose, we can create such an index in the following way:


listingSchema.index({ title: 'text', description: 'text'}) //creates index

A full text search can then be performed as follows:


Listing.find({ 
        $text: { 
             $search: "bake coffee cake" 
         } 
 })

This operation will then perform a search in the indexed fields, in this case title and discription, and return all the documents that match the query.

This operation also works perfectly well, quickly and efficiently thanks to the text index we're using. So far, so good; again.


Seeing that both operations work perfectly well, it would be easy to think that you could just aggregate both operations in a pipeline and find documents that both match the search query and the geospatial query. But you'd be wrong.

Problem: clash of indexes

The problem that arises if you try to combine geoSearch and textSearch like this, is that you cannot use two indexes simultaneously.

Unfortunately, there is no way to combine $geoNear and $text, because they both require an index to work. In part 2 of this series, I will discuss why this is the case in document-based databases like Mongo. In part 3, I will go over what solutions I'm considering to work around this limitation.