MongoDB Atlas Search
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
MongoDB Atlas Search embeds a Lucene-based search engine (internally, mongot) alongside your Atlas cluster,
kept in sync automatically, and queried through aggregation pipeline stages — giving
Elasticsearch/Solr-style
relevance ranking, analyzers, fuzzy matching, and faceting directly over your existing MongoDB documents, with no
second datastore or CDC pipeline to maintain (contrast with Elasticsearch’s "usually run alongside a system of
record" model). This page assumes the mapping and repository knowledge covered in
Spring Data MongoDB; a fuller reference lives in
Special Indexes & Search, which this page expands on at
the Spring Boot integration level.
Cloud-only: no local or on-premises option
|
Production Atlas Search (like Atlas Vector Search) is a MongoDB Atlas-only managed service — it does not exist in self-hosted MongoDB Community or Enterprise Server, and it carries no self-hosted equivalent, no multi-region option, and no SLA outside Atlas. That said, MongoDB publishes a purpose-built local Docker image, This is the key deployment-model difference versus Elasticsearch/Solr: all three are now Testcontainers-testable locally, but Atlas Search’s local image is MongoDB-provided test tooling distinct from the production service, whereas Elasticsearch’s and Solr’s containers run the same software as production. The comparison table below restates this alongside the other feature comparisons. |
Search indexes and analyzers
A search index is a separate JSON index definition — created via the Atlas UI, the Atlas CLI, mongosh, or
the driver/mongoTemplate’s search-index-management calls — distinct from a normal MongoDB index. It declares
field mappings (`dynamic, or explicit per field) and, per field, an analyzer.
Built-in analyzers include lucene.standard (the default), lucene.simple, lucene.whitespace,
lucene.keyword, and 40+ per-language analyzers (lucene.english, lucene.spanish, lucene.french,
lucene.german, lucene.cjk for Chinese/Japanese/Korean, and more) — pick the one matching each field’s content
language rather than relying on the standard analyzer for non-English text.
Atlas Search, Elasticsearch, and Solr all embed Apache Lucene, so the same character-filter → tokenizer → token-filter pipeline concept from Elasticsearch's "Analysis and mappings" section applies here too — just configured through the search index definition instead of the collection schema.
Stemming and n-grams
Stemming is handled by the per-language analyzers above (most built on Lucene’s Snowball stemmers) — the
same mechanism Elasticsearch’s language analyzers and Solr’s SnowballPorterFilterFactory use, since all three
are Lucene-based. There is no separate "stemming toggle": you pick a language analyzer.
N-grams are available two ways: the autocomplete field type (internally edge n-grams, for
partial-word/type-ahead matching) and, in a custom analyzer, an explicit nGram/edgeGram tokenizer — functionally equivalent to Elasticsearch’s ngram/edge_ngram token filters and Solr’s
NGramFilterFactory/EdgeNGramFilterFactory.
Querying with $search: fuzzy matching
The text operator inside a $search stage performs analyzed full-text matching and accepts a fuzzy option
(maxEdits: 1 or 2, prefixLength, maxExpansions) for typo-tolerant matching by edit distance — directly
analogous to Elasticsearch’s fuzzy query (or a match query’s fuzziness parameter) and to Solr’s ~N fuzzy
query syntax (or eDisMax’s fuzzy support). All three cap the edit distance at 2.
A minimal $search stage using text + fuzzy:
{
"$search": {
"index": "default",
"text": {
"query": "harry poter",
"path": "title",
"fuzzy": {
"maxEdits": 1,
"prefixLength": 0,
"maxExpansions": 50
}
}
}
}
Faceting with $searchMeta
Facet-only queries use the separate $searchMeta stage (or a facet collector alongside $search, read back
through the SEARCH_META aggregation variable) with string, number, or date facet types. This is
conceptually the same job as Elasticsearch’s aggregations (terms/histogram/date_histogram) and Solr’s
faceting (facet.field/facet.range). As with those two, a facet-friendly field generally needs a
non-analyzed representation — Atlas Search’s stringFacet type, versus Elasticsearch’s keyword type or
Solr’s un-analyzed copyField target.
A minimal $searchMeta facet query, counting documents per genre:
{
"$searchMeta": {
"index": "default",
"facet": {
"operator": { "exists": { "path": "genre" } },
"facets": {
"genreFacet": { "type": "string", "path": "genre" }
}
}
}
}
The result is a single metadata document (not matched documents) shaped as \{ count: \{ … }, facet: \{
genreFacet: \{ buckets: [ \{ _id: "fantasy", count: 2 }, … ] } } }.
Projection and stored source
Ordinary aggregation projection ($project after $search) works as usual, and $search adds a relevance
score exposed via \{ $meta: "searchScore" } — parallel to Elasticsearch’s _score and Solr’s relevance score.
The Atlas-specific angle is returnStoredSource: marking fields stored: true in the search index
definition lets $search return those values straight from the Lucene index, skipping the round-trip to the
collection — equivalent in purpose to Elasticsearch’s stored fields/_source filtering and Solr’s fl
parameter over stored fields.
Atlas Search vs. Elasticsearch vs. Solr
| Feature | MongoDB Atlas Search | Elasticsearch | Apache Solr |
|---|---|---|---|
Deployment model |
Atlas-only managed service in production — no self-hosted equivalent |
Self-hosted, Docker-friendly |
Self-hosted, Docker-friendly |
Local testing |
|
Testcontainers |
Testcontainers |
Fuzzy search |
|
|
|
Faceting |
|
Aggregations ( |
|
Projection & stored fields |
|
Stored fields & |
|
Per-language analyzers |
40+ |
Per-language analyzers |
Per- |
Stemming |
Built into the language analyzer, Snowball-based |
Same, Snowball-based |
Same, |
N-grams |
|
|
|
Choose Atlas Search when the data already lives in MongoDB Atlas and a second search cluster or CDC pipeline is unwanted; choose Elasticsearch/Solr when self-hosting is required, or the data does not already live in MongoDB. For the Solr/Elasticsearch-only head-to-head, see Solr vs. Elasticsearch.
Spring Boot integration
Spring Data MongoDB has no dedicated typed API for $search/$searchMeta — a proposal to add one
(spring-data-mongodb#3838, tracking
issue #3831) was declined upstream over
commercial-feature/licensing and testability concerns.
The native MongoDB Java driver underneath Spring Data MongoDB does have one, since driver 4.7: package
com.mongodb.client.model.search (SearchOperator, SearchOptions, SearchFacet, FuzzySearchOptions), plus
Aggregates.search(SearchOperator, SearchOptions) / Aggregates.searchMeta(SearchCollector, SearchOptions). No
extra starter or dependency is needed beyond spring-boot-starter-data-mongodb — the driver ships inside it
already. (The general custom-AggregationOperation extensibility mechanism documented at
Spring Data MongoDB — Aggregation Framework Support is useful background on extending aggregations in general, but the typed driver
builders below are the better, current answer for $search/$searchMeta specifically.)
Obtain the native collection via mongoTemplate.getCollection(…) — the same escape hatch
Spring Data MongoDB's "execute/CollectionCallback" section
documents for driver-level operations Spring Data doesn’t wrap. Results come back as raw Document, not a
mapped POJO/record: this bypasses Spring Data’s MappingMongoConverter, and the driver’s default codec
registry has no PojoCodecProvider registered, so aggregating into an application type here would fail with
CodecConfigurationException unless one were registered separately:
@Service
public class BookSearchService {
private final MongoCollection<Document> books;
public BookSearchService(MongoTemplate mongoTemplate) {
this.books = mongoTemplate.getCollection("books");
}
// fuzzy full-text match; the relevance score is projected into the result document
public List<Document> searchTitles(String query) {
List<Bson> pipeline = List.of(
Aggregates.search(
SearchOperator.text(fieldPath("title"), query)
.fuzzy(FuzzySearchOptions.fuzzySearchOptions().maxEdits(1)),
SearchOptions.searchOptions().index("default")),
Aggregates.project(Projections.fields(
Projections.include("title", "genre"),
Projections.computed("score", Document.parse("{ $meta: \"searchScore\" }")))));
return books.aggregate(pipeline).into(new ArrayList<>());
}
// prefix/type-ahead matching against the "title" field's autocomplete sub-type
public List<Document> autocompleteTitle(String prefix) {
List<Bson> pipeline = List.of(
Aggregates.search(
SearchOperator.autocomplete(fieldPath("title"), prefix),
SearchOptions.searchOptions().index("default")),
Aggregates.project(Projections.include("title", "genre")));
return books.aggregate(pipeline).into(new ArrayList<>());
}
// facet counts per genre via $searchMeta -- returns buckets, not matched documents
public Document facetByGenre() {
List<Bson> pipeline = List.of(
Aggregates.searchMeta(
SearchCollector.facet(
SearchOperator.exists(fieldPath("genre")),
List.of(SearchFacet.stringFacet("genreFacet", fieldPath("genre")))),
SearchOptions.searchOptions().index("default")));
return books.aggregate(pipeline, Document.class).first();
}
}
Search indexes themselves are managed outside the application — via the Atlas UI/CLI, or the driver’s
createSearchIndex used in the test setup below — not through @Indexed/@CompoundIndex.
Integration testing with Testcontainers
Follow Unit and Integration Testing's existing
@Testcontainers/@Container/@ServiceConnection house style rather than inventing a different testing
convention.
Add the org.testcontainers:mongodb dependency (test scope) — the same module that provides the plain
MongoDBContainer class used elsewhere for a bare MongoDB instance, just a different container type here:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
MongoDBAtlasLocalContainer extends GenericContainer directly (not MongoDBContainer), but Spring Boot
ships dedicated @ServiceConnection support for it specifically, so it still works without a manual
@DynamicPropertySource block. Because search-index updates are eventually consistent — not synchronous
with the write — the test polls with
Awaitility (already this section’s suggested tool for exactly this
kind of async-condition wait) until the index’s status reports "READY", then exercises fuzzy matching with
its relevance score, autocomplete, and faceting — the three query shapes just introduced — against the
container:
@SpringBootTest
@Testcontainers
class BookSearchIntegrationTest {
@Container
@ServiceConnection
static MongoDBAtlasLocalContainer mongodb =
new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:8.0.28");
@Autowired
private BookSearchService bookSearchService;
@BeforeAll
static void createSearchIndex(@Autowired MongoTemplate mongoTemplate) {
MongoCollection<Document> books = mongoTemplate.getCollection("books");
books.insertMany(List.of(
new Document("title", "Harry Potter and the Philosopher's Stone").append("genre", "fantasy"),
new Document("title", "The Hobbit").append("genre", "fantasy"),
new Document("title", "Dune").append("genre", "sci-fi")));
// "title" carries two types: an analyzed string for fuzzy search, and an autocomplete
// sub-type for prefix matching; "genre" is a stringFacet for the facet query below
String indexDefinitionJson = """
{
"mappings": {
"dynamic": false,
"fields": {
"title": [
{ "type": "string", "analyzer": "lucene.standard" },
{ "type": "autocomplete" }
],
"genre": { "type": "stringFacet" }
}
}
}
""";
books.createSearchIndex("default", BsonDocument.parse(indexDefinitionJson));
// search-index updates are eventually consistent -- wait for it to become queryable
// (this collection only ever has the one "default" index, so listSearchIndexes().first() is enough)
Awaitility.await().atMost(Duration.ofSeconds(10)).until(() ->
"READY".equals(books.listSearchIndexes().first().getString("status")));
}
@Test
void searchTitles_findsFuzzyMatchWithScore() {
List<Document> results = bookSearchService.searchTitles("harry poter");
assertThat(results).isNotEmpty();
Document topHit = results.get(0);
assertThat(topHit.getString("title")).isEqualTo("Harry Potter and the Philosopher's Stone");
assertThat(topHit.getDouble("score")).isGreaterThan(0.0);
}
@Test
void autocompleteTitle_returnsPrefixMatches() {
List<Document> results = bookSearchService.autocompleteTitle("har");
assertThat(results).extracting(d -> d.getString("title"))
.contains("Harry Potter and the Philosopher's Stone");
}
@Test
void facetByGenre_returnsCountsPerGenre() {
Document facetResult = bookSearchService.facetByGenre();
List<Document> buckets = facetResult.get("facet", Document.class)
.get("genreFacet", Document.class)
.getList("buckets", Document.class);
assertThat(buckets)
.extracting(b -> b.getString("_id"), b -> b.getInteger("count"))
.containsExactlyInAnyOrder(tuple("fantasy", 2), tuple("sci-fi", 1));
}
}
This container is for this application’s integration tests; it is not a substitute for validating against a real Atlas cluster before production — index-definition limits, resource sizing, and Atlas-specific operational behavior can still differ.