Java Faceted Full-Text Search API using MongoDB Atlas Search
Rate this tutorial
This is going to be a fun, practical tutorial demonstrating how to build a Java faceted full-text search API (like the ones powering sites like Amazon)!
We’ll use an interesting dataset which showcases how you can effectively pair machine learning/AI-generated data with more traditional search to produce fast, cheap, repeatable, and intuitive search engines.
TL;DR
If you (like me!) are less about words and more about code, you can jump straight in. Check it out and run it locally like this:
1 git clone https://github.com/luketn/atlas-search-coco 2 cd atlas-search-coco 3 docker compose up java-app
If you encounter an issue running the container and are on Mac OS Sequoia 15.2, you can add the environment variable as a workaround.
Alright, let’s walk through building this solution step by step. By the end, you will have all the tools you need to build your own awesome faceted search APIs.
We’ll leverage the powerful capabilities of MongoDB Atlas Search combined with the strong data modeling and high performance of Java to build it.
You’ll need the following on your machine:
- Java developer kit (JDK) 21+ and a Java IDE
- IntelliJ can also download and configure the JDK for you (e.g., Amazon Corretto 21).
- If you prefer another IDE, you should be able to translate the instructions pretty easily.
- Docker Desktop
Let’s create a new Java project!
Open your Java IDE and create a new Maven-based project, for Java 21.
In IntelliJ select File -> New Project…
Have a click around and you’ll see some awesome images and (importantly for us!) nice captions and labels for the categories of objects in each image.
The data here was generated by applying machine learning models to images to segment, caption, and label the features they include. Our search, however, will be lexical, fast, and independent of any machine learning or AI models.
When working with data in MongoDB, one of the most important steps is data modeling. Consider the structure and design of the collections, and each collection’s document schema.
In the raw COCO dataset, there are the following entities, each separate and linked by an integer ID:
Image: The height, width, image URL, license ID, and date captured
License: Indicates the license the image is under, and links to the license document (1-n)
Annotation (caption): An image ID and caption string describing the image (n-1)
Annotation (object): A bounding box, image ID, and category ID (n-1)
Category: A superCategory and category name, e.g., vehicle and car
When you’re modeling data in MongoDB, the primary consideration is how your application will be queried. In our case, we are going to expose a REST API search endpoint like:
/search?vehicle=car&text=red
In the results, we are going to want all the details of the images matching the facets (categories) and the full text (caption) parameters.
To support this intended query pattern, we are going to collapse all of these entities into a single hierarchical document schema, “Image,” that makes searching efficient and simple.
Here’s our schema, defined as a Java record:
1 import java.util.Date; 2 import java.util.List; 3 4 public record Image( 5 int _id, 6 //The caption describes the contents of the image and can be searched on using full text search. 7 String caption, 8 String url, 9 int height, 10 int width, 11 Date dateCaptured, 12 String licenseName, 13 String licenseUrl, 14 //True if the image shows a person. 15 boolean hasPerson, 16 //Following fields are 'super categories'. 17 // Each item in the list is a category. 18 // One or more objects of each category listed is present in the image. 19 List<String> accessory, 20 List<String> animal, 21 List<String> appliance, 22 List<String> electronic, 23 List<String> food, 24 List<String> furniture, 25 List<String> indoor, 26 List<String> kitchen, 27 List<String> outdoor, 28 List<String> sports, 29 List<String> vehicle 30 ) { }
We’ll have two collections in our MongoDB:
- Image: Will have documents in the schema above
- Category: Will have a list of the categories (and their super categories) which can be selected for filtering
1 public record Category( 2 int _id, 3 String superCategory, 4 String name 5 ) { }
Create the two records above in your Java project (feel free to use whichever namespace you like):
You can choose whether you’d like to run MongoDB Atlas locally in a Docker container:
1 docker run -d --name mongodb-atlas -p 27017:27017 mongodb/mongodb-atlas-local:8.0.3
If you encounter an issue running the container and are on Mac OS Sequoia 15.2, you can add the environment variable as a workaround.
Local connection string:
1 mongodb://localhost:27017/?directConnection=true
(Keep a note of the connection string to the cluster for the next step.)
Just to make things a little simpler, I’ve downloaded the COCO dataset and transformed it into the data model.
You’ll have two data files: one for each of our collections:
- AtlasSearchCoco.Category.json
- AtlasSearchCoco.Image.json
Then, we’ll use MongoDB Compass UI to ingest the JSON data and create our database.
Open the Compass app, connect to MongoDB using the connection string you noted down in the previous step, and create a new database using the + icon on the connection:
Enter atlasSearchCoco as the database name and image as our initial collection name:
Click Create Database to get started.
Click Import Data to bring in our JSON data:
Select the AtlasSearchCoco.Image.json file you downloaded and click Import.
You should see a note that you have imported 118,287 documents.
Next, create the category collection by clicking the + icon on the atlasSearchCoco database:
Click Create Collection.
As above, click Import Data but this time, select the file AtlasSearchCoco.Category.json:
Click Import to finish importing the categories, and you should see a note that you have imported 80 documents.
Congratulations! You now have a MongoDB Atlas cluster, with the COCO image dataset loaded into it.
Next, we’re going to create our Atlas Search index.
This is the heart of everything we’ll do in Java next, so we’ll take a bit of time to walk through each part of the index and explain what it means.
To create the index, go to the Indexes tab of the image collection in the MongoDB Compass UI.
Click Search Indexes to select the Atlas Search index tab:
Next, click Create Atlas Search Index:
Leave the name as default, and paste in the index definition:
1 { 2 "mappings": { 3 "fields": { 4 "caption": [{"type": "string"}], 5 "hasPerson": {"type": "boolean"}, 6 "accessory": [{"type": "token"}, {"type": "stringFacet"}], 7 "animal": [{"type": "token"}, {"type": "stringFacet"}], 8 "appliance": [{"type": "token"}, {"type": "stringFacet"}], 9 "electronic": [{"type": "token"}, {"type": "stringFacet"}], 10 "food": [{"type": "token"}, {"type": "stringFacet"}], 11 "furniture": [{"type": "token"}, {"type": "stringFacet"}], 12 "indoor": [{"type": "token"}, {"type": "stringFacet"}], 13 "kitchen": [{"type": "token"}, {"type": "stringFacet"}], 14 "outdoor": [{"type": "token"}, {"type": "stringFacet"}], 15 "sports": [{"type": "token"}, {"type": "stringFacet"}], 16 "vehicle": [{"type": "token"}, {"type": "stringFacet"}] 17 } 18 } 19 }
Click Create Search Index.
After a moment, you should see the status change to READY, indicating the index is created and ready to search:
Here are the index fields, and what their type definition(s) each do:
caption
1 "caption": [{"type": "string"}],
This field’s type definition is deceptively simple: string.
String fields can be searched in complex ways using the underlying Lucene search engine, and this field will be the main one searched by our Java search service.
When you create a field of type string, Lucene will iterate over all the documents, tokenizing the caption field string into small stems of words, making a short list of unique tokens. This is the secret sauce to what makes text search with Lucene so fast.
Each document is assigned an integer document ID in the Lucene index, and these can be efficiently stored because ranges can be compressed or skipped when searching.
Using a string type index allows you to perform advanced text searches like fuzzy matching, synonyms, and wildcards.
hasPerson
1 "hasPerson": {"type": "boolean"},
This field is a very simple type of index, essentially dividing the documents into three groups: boolean true, false, and undefined.
category fields - accessory, animal, appliance, electronic, food, furniture, indoor, kitchen, outdoor, sports, vehicle
1 "accessory": [{"type": "token"}, {"type": "stringFacet"}], 2 "animal": [{"type": "token"}, {"type": "stringFacet"}], 3 "appliance": [{"type": "token"}, {"type": "stringFacet"}], 4 "electronic": [{"type": "token"}, {"type": "stringFacet"}], 5 "food": [{"type": "token"}, {"type": "stringFacet"}], 6 "furniture": [{"type": "token"}, {"type": "stringFacet"}], 7 "indoor": [{"type": "token"}, {"type": "stringFacet"}], 8 "kitchen": [{"type": "token"}, {"type": "stringFacet"}], 9 "outdoor": [{"type": "token"}, {"type": "stringFacet"}], 10 "sports": [{"type": "token"}, {"type": "stringFacet"}], 11 "vehicle": [{"type": "token"}, {"type": "stringFacet"}]
These fields all have string array values in the collection. For example, in the animal array:
1 "animal": ["dog"]
They are indexed with Atlas Search in two different ways:
token: Token type indexes are on values which can be used for exact match filtering but cannot be used for advanced text searches. This is perfect for our use-case here because we will be allowing the API to include certain categories as filters.
stringFacet: String facet type indexes are used for counting potential exact matches for a given field value. We’ll use this to show the number of documents that would match each category if selected.
By combining these fields into a single index we can (all at once!) perform an advanced text search on the caption, filter by any category/categories we want, and collect the facet counts of the categories.
For example, this abbreviated search will find images with “frisbee” in the caption and a dog in the picture.
We’ll use a compound filter to combine multiple filter clauses in our example query. We will also count the number of potential matches for the animal and sports categories using the facet Atlas Search collector. The query is quite complex, but don’t worry too much about it for now—we’ll dive into each part of this query in more detail as we implement it in Java. For the moment, give it a try in Compass, and notice that you can see both the query results and facets all returned together. You can run the example in Compass by clicking on the Aggregations tab in the image collection, and pasting the following JSON into the text view:
1 [ 2 { 3 $search: { 4 facet: { 5 operator: { 6 compound: { 7 filter: [ 8 { 9 text: { 10 path: "caption", 11 query: "frisbee" 12 } 13 }, 14 { 15 equals: { 16 path: "animal", 17 value: "dog" 18 } 19 } 20 ] 21 } 22 }, 23 facets: { 24 animal: { 25 type: "string", 26 path: "animal", 27 numBuckets: 10 28 }, 29 sports: { 30 type: "string", 31 path: "sports", 32 numBuckets: 10 33 } 34 } 35 }, 36 count: { 37 type: "total" 38 } 39 } 40 }, 41 { 42 $facet: { 43 docs: [], 44 meta: [ 45 { 46 $replaceWith: "$$SEARCH_META" 47 }, 48 { 49 $limit: 1 50 } 51 ] 52 } 53 } 54 ]
Example result:
1 { 2 "docs": [ 3 { 4 "_id": 394, 5 "caption": "A dog is holding a frisbee standing on grass.", 6 "url": "http://images.cocodataset.org/train2017/000000000394.jpg", 7 "height": 611, 8 "width": 640, 9 "dateCaptured": { 10 "$date": "2013-11-18T09:44:51.000Z" 11 }, 12 "licenseName": "Attribution-NonCommercial-NoDerivs License", 13 "licenseUrl": "http://creativecommons.org/licenses/by-nc-nd/2.0/", 14 "hasPerson": false, 15 "animal": [ 16 "dog" 17 ], 18 "sports": [ 19 "frisbee" 20 ] 21 }, 22 ... 23 ], 24 "meta": [ 25 { 26 "count": { 27 "total": { 28 "$numberLong": "366" 29 } 30 }, 31 "facet": { 32 "sports": { 33 "buckets": [ 34 { 35 "_id": "frisbee", 36 "count": { 37 "$numberLong": "364" 38 } 39 }, 40 { 41 "_id": "sports ball", 42 "count": { 43 "$numberLong": "4" 44 } 45 }, 46 { 47 "_id": "baseball glove", 48 "count": { 49 "$numberLong": "1" 50 } 51 } 52 ] 53 }, 54 "animal": { 55 "buckets": [ 56 { 57 "_id": "dog", 58 "count": { 59 "$numberLong": "366" 60 } 61 } 62 ] 63 } 64 } 65 } 66 ] 67 }
Alright! Let’s implement our Java service class.
Let’s not mince words. Atlas Search syntax is complex, especially if you get into multiple compound clauses and conditions. Faceting adds an extra layer of complexity, and getting the results out of the search alongside the facets in a single query further complicates it.
I think the MongoDB Java driver, and Java in general, helps mitigate the complexity in a few ways:
- Strong types: Once you’ve established your data model, you can easily define it as immutable Java record types, and the driver will take care of serializing/deserializing. Once you have this in place, you can be confident that your business logic code is correct and safe.
- Fluent builder syntax: Most of the operations you need to use in Atlas Search have helper methods to help you compose the search operations and build up your query.
- Great tests: Having built similar solutions with NodeJS and Python, I find the testing solutions and rigour around code coverage and test completeness easier to achieve and better supported in Java. Having a comprehensive set of tests around your search code is super important to make the code intent clear, rehearse the golden paths and edge cases, and prevent regression.
Starting with the basics, let’s create an EntryPoint class, initialize a connection to MongoDB, and return some data:
First, add the dependencies for MongoDB, a JSON serializer (Jackson), and a simple logging framework to your Maven pom.xml:
1 <dependencies> 2 <dependency> 3 <groupId>org.mongodb</groupId> 4 <artifactId>mongodb-driver-sync</artifactId> 5 <version>5.2.0</version> 6 </dependency> 7 <dependency> 8 <groupId>com.fasterxml.jackson.core</groupId> 9 <artifactId>jackson-databind</artifactId> 10 <version>2.17.2</version> 11 </dependency> 12 <dependency> 13 <groupId>org.slf4j</groupId> 14 <artifactId>slf4j-simple</artifactId> 15 <version>2.0.13</version> 16 </dependency> 17 </dependencies>
The JSON serializer will be used to return our data from the API, and the MongoDB driver will use the SL4J logger to write its logs to the console (or wherever you configure it to write).
Then, create your EntryPoint class:
1 package com.mycodefu; 2 3 import com.fasterxml.jackson.databind.ObjectMapper; 4 import com.mongodb.client.MongoClient; 5 import com.mongodb.client.MongoClients; 6 import com.mongodb.client.MongoCollection; 7 import com.mongodb.client.MongoDatabase; 8 import com.sun.net.httpserver.HttpServer; 9 10 import java.io.IOException; 11 import java.net.InetSocketAddress; 12 import java.net.URLDecoder; 13 import java.nio.charset.StandardCharsets; 14 import java.util.ArrayList; 15 import java.util.Arrays; 16 import java.util.List; 17 import java.util.Map; 18 import java.util.stream.Collectors; 19 20 public class Main { 21 22 public static void main(String[] args) throws IOException { 23 24 String connectionString = "mongodb://localhost:27017"; 25 MongoClient mongoClient = MongoClients.create(connectionString); 26 MongoDatabase database = mongoClient.getDatabase("atlasSearchCoco"); 27 MongoCollection<Category> categoryCollection = database.getCollection("category", Category.class); 28 MongoCollection<Image> imageCollection = database.getCollection("image", Image.class); 29 30 ObjectMapper objectMapper = new ObjectMapper(); 31 32 HttpServer httpServer = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0); 33 34 httpServer.createContext("/categories", exchange -> { 35 List<Category> categories = categoryCollection.find().into(new ArrayList<>()); 36 String categoriesJson = objectMapper.writeValueAsString(categories); 37 byte[] categoriesJsonBytes = categoriesJson.getBytes(); 38 39 exchange.sendResponseHeaders(200, categoriesJsonBytes.length); 40 exchange.getResponseBody().write(categoriesJsonBytes); 41 exchange.close(); 42 }); 43 44 httpServer.createContext("/images", exchange -> { 45 Map<String, List<String>> params = Arrays.stream(exchange.getRequestURI().getQuery().split("&")) 46 .map(param -> param.split("=", 2)) 47 .map(pair -> new String[]{ 48 URLDecoder.decode(pair[0], StandardCharsets.UTF_8), 49 URLDecoder.decode(pair[1], StandardCharsets.UTF_8) 50 }) 51 .collect(Collectors.groupingBy( 52 pair -> pair[0], 53 Collectors.mapping( 54 pair -> pair[1], 55 Collectors.toList() 56 ) 57 )); 58 59 //TODO: Implement the search for images based on the query parameters. For now we just return the first image. 60 List<Image> images = imageCollection.find().limit(1).into(new ArrayList<>()); 61 //---------------- 62 63 String imagesJson = objectMapper.writeValueAsString(images); 64 byte[] imagesJsonBytes = imagesJson.getBytes(); 65 66 exchange.sendResponseHeaders(200, imagesJsonBytes.length); 67 exchange.getResponseBody().write(imagesJsonBytes); 68 exchange.close(); 69 }); 70 71 httpServer.start(); 72 73 System.out.println("Server started on port http://localhost:8080"); 74 System.out.println("Try listing categories at http://localhost:8080/categories"); 75 System.out.println("Try searching for images at http://localhost:8080/images?caption=motorcycle"); 76 } 77 }
Run the application and you should then be able to load the URLs in the browser:
And:
There are a few things to notice about the main class implementation:
The Mongo connection, database, and collection instances—the first thing we do is establish a connection to MongoDB and typed instances of collection classes which allow us to query the data:
1 String connectionString = "mongodb://localhost:27017"; 2 MongoClient mongoClient = MongoClients.create(connectionString); 3 MongoDatabase database = mongoClient.getDatabase("atlasSearchCoco"); 4 MongoCollection<Category> categoryCollection = database.getCollection("category", Category.class); 5 MongoCollection<Image> imageCollection = database.getCollection("image", Image.class);
The Category and Image types are the records we created earlier, which represent the COCO domain model for our project.
We also create an ObjectMapper which is the Jackson JSON serializer we’ll use to write JSON to the client.
Next, we used Java’s built-in HTTP server to create an API server, query the database, and return JSON:
1 HttpServer httpServer = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0); 2 3 httpServer.createContext("/categories", exchange -> { 4 List<Category> categories = categoryCollection.find().into(new ArrayList<>()); 5 String categoriesJson = objectMapper.writeValueAsString(categories); 6 byte[] categoriesJsonBytes = categoriesJson.getBytes(); 7 8 exchange.sendResponseHeaders(200, categoriesJsonBytes.length); 9 exchange.getResponseBody().write(categoriesJsonBytes); 10 exchange.close(); 11 }); 12 … 13 httpServer.start();
Of course, we could use a framework like Springboot here, but to keep the focus on MongoDB Atlas Search, we’ll just use this little basic HTTP server implementation for our project.
One of my favourite things about Java’s client for MongoDB is that we can easily take advantage of the strength of the type system. Our domain model as defined by the immutable Category record is strongly enforced here in all logic interacting with the database. Whilst our schema in MongoDB is free to evolve, the place we are controlling that evolution is here in our Java code. Any changes to the model will be deliberate and enforced at compile time and runtime in our service. Typically, our client—let’s say a browser UI—would also be loosely typed JSON (not enforcing the data model). For me, the service is the right place for such control to be enforced, so our schema evolves with our API.
Next, we have our placeholder for the image search:
1 httpServer.createContext("/images", exchange -> { 2 Map<String, List<String>> params = Arrays.stream(exchange.getRequestURI().getQuery().split("&")) 3 .map(param -> param.split("=", 2)) 4 .map(pair -> new String[]{ 5 URLDecoder.decode(pair[0], StandardCharsets.UTF_8), 6 URLDecoder.decode(pair[1], StandardCharsets.UTF_8) 7 }) 8 .collect(Collectors.groupingBy( 9 pair -> pair[0], 10 Collectors.mapping( 11 pair -> pair[1], 12 Collectors.toList() 13 ) 14 )); 15 16 //TODO: Implement the search for images based on the query parameters. For now we just return the first image. 17 List<Image> images = imageCollection.find().limit(1).into(new ArrayList<>()); 18 //---------------- 19 20 String imagesJson = objectMapper.writeValueAsString(images); 21 byte[] imagesJsonBytes = imagesJson.getBytes(); 22 23 exchange.sendResponseHeaders(200, imagesJsonBytes.length); 24 exchange.getResponseBody().write(imagesJsonBytes); 25 exchange.close(); 26 });
Here, we are adding a nice safe ingestion of the query params into a map, and for now, just return the first image. Note that the map is from String to List<String>, which is important because our query params may include 1-n instances of the same parameter. We’ll use that in the next step to filter on multiple values in the search.
Our last step: the search!
We’re going to add one more record type to our list of models, which will allow us to return both a paged list of Image records, as well as the facet counts:
1 import java.util.List; 2 3 public record ImageSearchResult(List<Image> docs, List<ImageMeta> meta) { 4 public record ImageMeta (ImageMetaTotal count, ImageMetaFacets facet) { } 5 public record ImageMetaTotal (long total) { } 6 public record ImageMetaFacets ( 7 ImageMetaFacet accessory, 8 ImageMetaFacet animal, 9 ImageMetaFacet appliance, 10 ImageMetaFacet electronic, 11 ImageMetaFacet food, 12 ImageMetaFacet furniture, 13 ImageMetaFacet indoor, 14 ImageMetaFacet kitchen, 15 ImageMetaFacet outdoor, 16 ImageMetaFacet sports, 17 ImageMetaFacet vehicle 18 ) { } 19 public record ImageMetaFacet (List<ImageMetaFacetBucket> buckets) { } 20 public record ImageMetaFacetBucket (String _id, long count) { } 21 }
Save this as a new record in your project alongside Image and Category.
This record represents the result document which will be returned by our aggregate search query.
The first field, docs, is a single page of image documents, and the second field, meta, contains the metadata for all the documents which match the search criteria.
The metadata shows the total count of all documents that match, as well as the counts per facet.
In our case, we have facets for each category of object which may be in the image. As an example, let’s say we had searched for “dog” in the caption of an image. We might expect to see a high value on the animal->dog facet count, but we’ll see a lot of other facets with counts too.
For example, you might notice a count of 68 on the sports->surfboard facet.
Knowing this would allow you to further filter the results like:
Or at least, it will! Let’s implement the image search API.
Because there is a bit of complexity in this query, let’s create a new method in the Main class to handle the search. Insert the following after your main method:
1 private static ImageSearchResult search(MongoCollection<Image> imageCollection, String caption, Integer page, Boolean hasPerson, List<String> accessory, List<String> animal, List<String> appliance, List<String> electronic, List<String> food, List<String> furniture, List<String> indoor, List<String> kitchen, List<String> outdoor, List<String> sports, List<String> vehicle) { 2 int skip = 0; 3 int pageSize = 5; 4 if (page != null) { 5 skip = page * pageSize; 6 } 7 8 9 List<SearchOperator> clauses = new ArrayList<>(); 10 if (caption != null) { 11 clauses.add(SearchOperator 12 .text( 13 fieldPath("caption"), 14 caption 15 ) 16 ); 17 } 18 if (hasPerson != null) { 19 clauses.add(equals("hasPerson", hasPerson)); 20 } 21 BiConsumer<String, List<String>> addConditional = (String category, List<String> values) -> { 22 if (values != null) { 23 for (String value : values) { 24 clauses.add(equals(category, value)); 25 } 26 } 27 }; 28 addConditional.accept("accessory", accessory); 29 addConditional.accept("animal", animal); 30 addConditional.accept("appliance", appliance); 31 addConditional.accept("electronic", electronic); 32 addConditional.accept("food", food); 33 addConditional.accept("furniture", furniture); 34 addConditional.accept("indoor", indoor); 35 addConditional.accept("kitchen", kitchen); 36 addConditional.accept("outdoor", outdoor); 37 addConditional.accept("sports", sports); 38 addConditional.accept("vehicle", vehicle); 39 40 List<StringSearchFacet> facets = List.of( 41 stringFacet("accessory", fieldPath("accessory")).numBuckets(10), 42 stringFacet("animal", fieldPath("animal")).numBuckets(10), 43 stringFacet("appliance", fieldPath("appliance")).numBuckets(10), 44 stringFacet("electronic", fieldPath("electronic")).numBuckets(10), 45 stringFacet("food", fieldPath("food")).numBuckets(10), 46 stringFacet("furniture", fieldPath("furniture")).numBuckets(10), 47 stringFacet("indoor", fieldPath("indoor")).numBuckets(10), 48 stringFacet("kitchen", fieldPath("kitchen")).numBuckets(10), 49 stringFacet("outdoor", fieldPath("outdoor")).numBuckets(10), 50 stringFacet("sports", fieldPath("sports")).numBuckets(10), 51 stringFacet("vehicle", fieldPath("vehicle")).numBuckets(10) 52 ); 53 54 55 List<Bson> aggregateStages = List.of( 56 Aggregates.search( 57 SearchCollector.facet( 58 SearchOperator.compound().filter(clauses), 59 facets 60 ), SearchOptions.searchOptions().count(SearchCount.total())), 61 Aggregates.skip(skip), 62 Aggregates.limit(pageSize), 63 Aggregates.facet( 64 new Facet("docs", List.of()), 65 new Facet("meta", List.of( 66 Aggregates.replaceWith("$$SEARCH_META"), 67 Aggregates.limit(1) 68 )) 69 ) 70 ); 71 72 73 ImageSearchResult imageSearchResult = imageCollection.aggregate(aggregateStages, ImageSearchResult.class).first(); 74 75 76 return imageSearchResult; 77 } 78 79 private static SearchOperator equals(String fieldName, Object value) { 80 return SearchOperator.of( 81 new Document("equals", new Document() 82 .append("path", fieldName) 83 .append("value", value) 84 )); 85 }
Next, let’s call the new function from our /image service handler. Replace the TODO you left earlier:
1 //TODO: Implement the search for images based on the query parameters. For now we just return the first image. 2 List<Image> images = imageCollection.find().limit(1).into(new ArrayList<>()); 3 //----------------
With:
1 ImageSearchResult images = search(imageCollection, 2 params.containsKey("caption") ? params.get("caption").getFirst() : null, 3 params.containsKey("page") ? Integer.parseInt(params.get("page").getFirst()) : null, 4 params.containsKey("hasPerson") ? Boolean.parseBoolean(params.get("hasPerson").getFirst()) : null, 5 params.get("accessory"), 6 params.get("animal"), 7 params.get("appliance"), 8 params.get("electronic"), 9 params.get("food"), 10 params.get("furniture"), 11 params.get("indoor"), 12 params.get("kitchen"), 13 params.get("outdoor"), 14 params.get("sports"), 15 params.get("vehicle") 16 );
This will take the query parameters passed to the API and call our search function. Let’s give it a spin, and then we’ll come back and break down the search method piece by piece.
You can now start to see how the facets work. Let’s search for the term “riding” in our image caption, and further filter down to only images having a horse and a suitcase:
Amazing. 🙂
So, let’s go through the search method and explain each part piece by piece.
Our aggregate search on the image MongoDB collection will have the following stages:
1 [ 2 { 3 $search: { 4 facet: { 5 operator: { 6 compound: { 7 filter: [ <Filter clauses go here!> ] 8 } 9 }, 10 facets: { <List the facets we want returned> } 11 } 12 } 13 }, 14 <Paging> 15 {"$skip": 0},{"$limit": 5}, 16 <Return structure - page of docs + meta (facet counts)> 17 {$facet: {docs: [], meta: [...]}} 18 ]
You can see the composition of each stage in the Java code:
1 int skip = 0; 2 int pageSize = 5; 3 if (page != null) { 4 skip = page * pageSize; 5 }
First up, we calculate the number of documents to skip for paging, and set the page size to 5. These are passed to the skip and limit stages.
In this section of the search method, we put together a List<Bson> which will be the filter clauses:
1 List<SearchOperator> clauses = new ArrayList<>(); 2 if (caption != null) { 3 clauses.add(SearchOperator 4 .text( 5 fieldPath("caption"), 6 caption 7 ) 8 ); 9 } 10 if (hasPerson != null) { 11 clauses.add(equals("hasPerson", hasPerson)); 12 } 13 BiConsumer<String, List<String>> addConditional = (String category, List<String> values) -> { 14 if (values != null) { 15 for (String value : values) { 16 clauses.add(equals(category, value)); 17 } 18 } 19 }; 20 addConditional.accept("accessory", accessory); 21 addConditional.accept("animal", animal); 22 addConditional.accept("appliance", appliance); 23 addConditional.accept("electronic", electronic); 24 addConditional.accept("food", food); 25 addConditional.accept("furniture", furniture); 26 addConditional.accept("indoor", indoor); 27 addConditional.accept("kitchen", kitchen); 28 addConditional.accept("outdoor", outdoor); 29 addConditional.accept("sports", sports); 30 addConditional.accept("vehicle", vehicle);
We use a little helper method, addConditional, which checks if the parameter was null, and if not, adds each value with an equals clause for the categories. Another small helper method, equals(), constructs the Document for our equals clause.
The first clause uses the text search operator. There is a huge depth to this operator, which we won’t cover in full here, but you can customize this to support:
- Simple text search (like we’re using here).
- Configurable fuzzy text searching to handle typos—“ridung” -> riding.
- Wildcards like “rid*”.
- Complex query strings like “riding AND NOT (bicycle OR horse)”.
- Phrases to find multi-word sequences.
- Combining more than one of these approaches in a single search, and rank the results by the best intersection of matching results.
Then, we put together a list of the facets we want to return:
1 List<StringSearchFacet> facets = List.of( 2 stringFacet("accessory", fieldPath("accessory")).numBuckets(10), 3 stringFacet("animal", fieldPath("animal")).numBuckets(10), 4 stringFacet("appliance", fieldPath("appliance")).numBuckets(10), 5 stringFacet("electronic", fieldPath("electronic")).numBuckets(10), 6 stringFacet("food", fieldPath("food")).numBuckets(10), 7 stringFacet("furniture", fieldPath("furniture")).numBuckets(10), 8 stringFacet("indoor", fieldPath("indoor")).numBuckets(10), 9 stringFacet("kitchen", fieldPath("kitchen")).numBuckets(10), 10 stringFacet("outdoor", fieldPath("outdoor")).numBuckets(10), 11 stringFacet("sports", fieldPath("sports")).numBuckets(10), 12 stringFacet("vehicle", fieldPath("vehicle")).numBuckets(10) 13 );
Here, we are saying that we want all of the fields we’ve indexed with stringFacet in our Atlas Search index, allowing them to be counted. We’re also specifying 10 as the maximum number of buckets to count in. This means we’ll get the top 10 results per super category, with their counts. For example, let’s say there were 15 animals which had a count for a search on the caption “grass.” In that case, we would receive only the top 10 animal counts—the five least significant would be omitted.
Finally, we put together all the aggregate stages and call the search:
1 List<Bson> aggregateStages = List.of( 2 Aggregates.search( 3 SearchCollector.facet( 4 SearchOperator.compound().filter(clauses), 5 facets 6 ), SearchOptions.searchOptions().count(SearchCount.total())), 7 Aggregates.skip(skip), 8 Aggregates.limit(pageSize), 9 Aggregates.facet( 10 new Facet("docs", List.of()), 11 new Facet("meta", List.of( 12 Aggregates.replaceWith("$$SEARCH_META"), 13 Aggregates.limit(1) 14 )) 15 ) 16 ); 17 18 ImageSearchResult imageSearchResult = imageCollection.aggregate(aggregateStages, ImageSearchResult.class).first(); 19 return imageSearchResult;
The most interesting part, and perhaps also the most confusing part, is the use of the final aggregate stage, facet. This is a different use of the term facet, wherein we are creating two facets for our aggregate result. This is a little piece of Atlas Search magic that allows us to return the paged documents of the search, alongside the meta data for the facets. Best not to think about it too much. Grit your teeth and put it in there.
If you really must know, you can find the documentation for using the facet collector with the $$SEARCH_META aggregation framework variable.
If you’d like to, you can check out the same code refactored out into a few separate classes in a better overall structure which could be the foundation of a real API server.
This project also has the code used to download and transform the COCO dataset into our domain model and create the index. There are some goodies if you go exploring around unit testing for Atlas Search using the incredible Test Containers project.
So there you have it. The beginnings of an awesome service using Atlas Search to perform advanced text search, filtering, and facet counting!
The use of the COCO dataset here shows an interesting example of how data generated using machine learning can be combined with more traditional lexical text searching. This provides repeatable, consistent, and intuitive search results to consumers of your API.
Faceting allows us to create filterable result sets with counts on each filterable category returned. This supports advanced user interfaces in a highly performant single search query.
Using the Java language and the Java Virtual Machine (JVM) as the runtime provides a highly consistent, strongly typed, and reliable platform for building scalable APIs. The language features in particular pair well with MongoDB. Java is a great language to enforce your schema with and evolve it over time. This combination of compile time and runtime checks for consistency in the code schema, along with flexible changes in the MongoDB database schema as you evolve it over time, is a great match.
Read more about Atlas Search, Atlas Search faceting, running Atlas locally with Docker, and the Java driver for MongoDB’s Atlas search features.
If you have any questions or would like to know more, please don’t hesitate to reach out to me on LinkedIn.
Happy coding!
Top Comments in Forums
There are no comments on this article yet.