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, mongodb/mongodb-atlas-local, bundling mongod plus the mongot search process specifically so $search/$searchMeta can be developed and tested fully offline. This is dev/test tooling, not a way to self-host the production service — it offers no HA, no scaling, and no SLA. See == Integration testing with Testcontainers below for how to use it.

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

MongoDBAtlasLocalContainer wrapping the MongoDB-provided mongodb/mongodb-atlas-local dev/test image — not the production service

Testcontainers elasticsearch module — same software as production

Testcontainers SolrContainer — same software as production

Fuzzy search

fuzzy option on the text operator

fuzzy query, or a match query’s fuzziness

~N fuzzy query syntax, or eDisMax fuzzy support

Faceting

$searchMeta facet types (stringFacet/numberFacet/dateFacet)

Aggregations (terms/histogram/date_histogram)

facet.field + facet.range

Projection & stored fields

returnStoredSource

Stored fields & _source filtering

fl + stored fields

Per-language analyzers

40+ lucene.<language> analyzers

Per-language analyzers

Per-fieldType analysis chains with language filters

Stemming

Built into the language analyzer, Snowball-based

Same, Snowball-based

Same, SnowballPorterFilterFactory

N-grams

autocomplete field type, or nGram/edgeGram tokenizer

ngram/edge_ngram token filters

NGramFilterFactory/EdgeNGramFilterFactory

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.