DEV Community

Cover image for Beyond Keywords: Implementing Semantic Search in Java With Spring Data (Part 1)
Ricardo Mello for MongoDB

Posted on

Beyond Keywords: Implementing Semantic Search in Java With Spring Data (Part 1)

Building a semantic movie search app with embeddings and vector queries

Have you ever tried to search for something such as a product, a song, or a movie but couldn’t quite remember its exact name? Maybe you recall only a clue—a desert pyramid, a short melody, or “that ship that hit an iceberg.” Keyword search struggles with that. Vector search doesn’t: It lets you search by meaning.

It works by turning text into embeddings, vectors (arrays of numbers) that capture semantic similarity, so results are ranked by what they mean, not just what they say.

With recent vector query support in Spring Data, Java developers can build semantic search using familiar repositories and queries.

In this article, we’ll build a small movie search app that understands intent beyond keywords. You’ll type queries like “movie with pyramids in Egypt” or “a science fiction movie about rebels fighting an empire in space” and the app will surface relevant titles.

The movie search web application interface

Along the way, we’ll explore how to generate embeddings, perform vector searches, and retrieve the most relevant results.

The magic behind vector search

When searching for a movie in the past, the most common approach was keyword-based. You’d type something like title = "Star Wars", and the system would return the exact match.

But if your query was anything different, maybe a misspelling, a synonym, or simply because you couldn’t remember the title, it became much harder to get the right result. And it got even worse if all you had in mind was a scene or a general idea of the story.

For example, if you searched for “a science fiction movie about rebels fighting an empire in space,”* a keyword engine would struggle. To make that work, you’d have to set up synonym lists, custom rules, and a lot of manual mappings to connect this description back to *Star Wars, a process that would take a lot of effort and resources to maintain.

Vector search takes a very different approach. Instead of looking for literal keywords, it looks for similarity in meaning. The process works like this:

  1. Generate embeddings: Unstructured data such as text, audio, or images is sent to a machine learning model, which converts it into an embedding (a numerical vector), and is stored in the database.
  2. Convert the user query: When a user types a search, the query is also transformed into an embedding by the same model.
  3. Compare vectors: The query embedding is compared against the embeddings stored in the database, and the closest matches are returned.

Together, these three steps form the foundation of vector search.

Note: It is recommended to use the same model for both creating and querying embeddings. For example, if the dataset was embedded with Voyage AI, the queries should also be embedded with Voyage AI to ensure the most accurate and meaningful results.

Prerequisites

Before we start building the application, make sure you have the following in place:

Embeddings with Voyage AI

Voyage AI is an embedding platform offering high-quality, production-ready models behind a simple API. In this project, we use Voyage AI to generate embeddings in two places:

  1. Generate embeddings: Each movie’s plot was sent to voyage-3-large, and the returned vector was stored in the embedded_movies collection as plot_embedding_voyage_3_large. (This preprocessing is already done.)

The plot embedding flow

  1. Generate embeddings (user query): When the user searches, we encode the query with the same voyage-3-large model and compare that query vector to the stored document vectors. We then return the most similar movies.

The vector search flow

The similarity comparison is executed by MongoDB Atlas Vector Search against the stored vectors.

If you’d like to explore more details about the model we use here, you can check the official Voyage AI blog post.

Preparing the dataset

Before creating the index, make sure the embedded_movies collection has been imported into your MongoDB Atlas cluster. In our case, this dataset already comes with a field called plot_embedding_voyage_3_large, which stores the pre-computed embeddings for each movie plot.

The embedded_movies collection

With the dataset in place, the next step is to create a vector index so MongoDB Atlas knows which field to use, its dimensionality, and the similarity metric.

MongoDB Atlas Vector Search (index and retrieval)

To compare embeddings at query time, MongoDB Atlas needs a search index that tells it which field stores your vectors, their dimensionality, and which similarity metric to use. Once the collection is in place, create the following index:

db.embedded_movies.createSearchIndex(
  "vector_index",
  "vectorSearch",
  { "fields": [{
    "type": "vector",
    "path": "plot_embedding_voyage_3_large",
    "numDimensions": 2048,
    "similarity": "dotProduct"
  }]}
)
Enter fullscreen mode Exit fullscreen mode

Let's break it down:

  • "vector_index”: the index name
  • path: the field that stores document embeddings
  • numDimensions: must match the model’s embedding size
  • similarity: metric used for nearest-neighbor ranking (e.g., dotProduct, cosine, euclidean)

Building the movie search app

Now that we’ve seen what vector search is, how embeddings are generated, and created the vector index in MongoDB Atlas, let’s put everything into practice. To get started, open Spring Initializr, create a new project, and select Spring Web and Spring Data MongoDB as dependencies. Download the project and open it in your favorite IDE.

The spring initializr page

Configuring the application

After opening the project, the first thing is to configure our MongoDB connection and a few settings for the embedding provider and vector search. Open or create your application.yml file:

spring:
 data:
   mongodb:
     uri: ${MONGODB_URI}
     database: sample_mflix
voyage:
 api-key: ${VOYAGE_API_KEY}
 base-url: https://api.voyageai.com/v1
 model: voyage-3-large
 output-dimension: 2048
 vector-index-name: vector_index
 vector-collection-name: embedded_movies
 vector-field: plot_embedding_voyage_3_large
 top-k: 8
 num-candidates: 160
Enter fullscreen mode Exit fullscreen mode

What this does (briefly):

  • MongoDB: connects Spring Data to your Atlas cluster and the embedded_movies collection
  • Voyage: sets up the API key, model, and embedding size
  • Vector Search: tells MongoDB Atlas which index and field to use, plus how many results (top-k) to return

Next, we need to connect our application.yml settings to the code. To do that, we create a @ConfigurationProperties record (VoyageConfigProperties) that maps all voyage.* values into a strongly-typed object we can use later in the application.

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "voyage")
public record VoyageConfigProperties(
    String model,
    int outputDimension,
    String vectorIndexName,
    String vectorCollectionName,
    String vectorField,
    int topK,
    int numCandidates,
    String baseUrl,
    String apiKey) {}
Enter fullscreen mode Exit fullscreen mode

The document model

Our embedded_movies collection contains several fields that describe each movie, such as title, year, plot, and cast. To work with this data in our application, we’ll define a simple record that maps to the collection but only includes the fields we want to return to the client. Create a record named Movie and annotate it with Document("embedded_movies"):

import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;

@Document("embedded_movies")
public record Movie(
   String title,
   String year,
   String fullplot,
   String plot,
   String poster,
   Imdb imdb,
   List<String> genres,
   List<String> cast)
{
   record Imdb(Double rating) {}
}
Enter fullscreen mode Exit fullscreen mode

Wire the request DTO

Next, let’s create a request record with a single query field to hold the user’s search text, for now. We’ll revisit this class later to add extra fields for filtering:

public record MovieSearchRequest(
      String query
) {}
Enter fullscreen mode Exit fullscreen mode

Communicating with Voyage AI

In this step, we’ll set up the classes needed to talk to the Voyage AI API. The idea is simple: We send a request with some text, and Voyage AI returns the corresponding list of embeddings.

To model this exchange, we’ll use two records:

EmbeddingsRequest: This represents the payload we send to Voyage AI. It includes the input text, the model name, and a few optional parameters like input_type and output_dimension.

import java.util.List;

public record EmbeddingsRequest(
   List<String> input,
   String model,
   String input_type,
   Integer output_dimension
) {}
Enter fullscreen mode Exit fullscreen mode

EmbeddingsResponse: This represents the response from Voyage AI.

import java.util.List;

public record EmbeddingsResponse(List<Item> data) {
 public record Item(List<Double> embedding) {}
}
Enter fullscreen mode Exit fullscreen mode

The VoyageEmbeddingsClient

To call the Voyage AI API, we’ll define a small HTTP client using Spring’s declarative HTTP interfaces:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

@HttpExchange(
       url = "/embeddings",
       contentType = MediaType.APPLICATION_JSON_VALUE,
       accept = MediaType.APPLICATION_JSON_VALUE
)
public interface VoyageEmbeddingsClient {
 @PostExchange
 EmbeddingsResponse embed(@RequestBody EmbeddingsRequest body);
}
Enter fullscreen mode Exit fullscreen mode

In short: This interface acts as a strongly-typed wrapper around Voyage AI’s /embeddings endpoint, letting us call the API as if it were a regular Java method.

The VoyageClientConfig

To actually use our VoyageEmbeddingsClient, we need to configure how Spring will build it. That’s what the following class does:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration
public class VoyageClientConfig {

  @Bean
  public VoyageEmbeddingsClient voyageEmbeddingsClient(VoyageConfigProperties props) {
      RestClient client = RestClient.builder()
          .baseUrl(props.baseUrl())
          .defaultHeader("Authorization", "Bearer " + props.apiKey())
          .defaultHeader("Content-Type", "application/json")
          .build();
      HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(client)).build();
      return factory.createClient(VoyageEmbeddingsClient.class);
  }
}
Enter fullscreen mode Exit fullscreen mode

In short: This config builds the HTTP client, injects the API key into every request, and exposes a ready-to-use VoyageEmbeddingsClient bean.

The EmbeddingService

Next, let's add an EmbeddingService that wraps our client and handles generating embeddings for a given query text.

import org.springframework.stereotype.Service;
import java.util.List;
import java.util.logging.Logger;

@Service
public class EmbeddingService {

  private final Logger logger = Logger.getLogger(EmbeddingService.class.getName());
  private final VoyageEmbeddingsClient client;
  private final VoyageConfigProperties config;

  public EmbeddingService(VoyageEmbeddingsClient client, VoyageConfigProperties config) {
     this.client = client;
     this.config = config;
  }
  public List<Double> embedQuery(
        String text) {
     logger.info("Generating embeddings .. ");
     var res = client.embed(new EmbeddingsRequest(
           List.of(text), config.model(), "query", config.outputDimension()));
     logger.info("Embeddings generated successfully!");
     return res.data().getFirst().embedding();
  }
}
Enter fullscreen mode Exit fullscreen mode

This service calls the Voyage AI API with the user’s text, generates the embedding using the configured model, and returns the vector as a list of numbers.

Querying with Spring Data Vector Search operation

There are multiple ways to run a vector search. You could even work directly with raw document queries. But in this tutorial, we’ll focus on the brand-new Spring Data MongoDB support for semantic search, introduced in Spring Data MongoDB 4.5.

The VectorSearchOperation class is at the core of this feature, and it’s what we’ll use to express our queries in a clean, type-safe way.

To run the search, let’s create a MovieService that generates embeddings for the user’s query and executes the vector search against the embedded_movies collection:

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.VectorSearchOperation;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
@EnableConfigurationProperties(VoyageConfigProperties.class)
public class MovieService {

   private final MongoTemplate mongoTemplate;

   private final VoyageConfigProperties config;
   private final EmbeddingService embeddingService;

   MovieService(MongoTemplate mongoTemplate, VoyageConfigProperties config, EmbeddingService embeddingService) {
      this.mongoTemplate = mongoTemplate;
      this.config = config;
      this.embeddingService = embeddingService;
   }

   public List<Movie> searchMovies(MovieSearchRequest req) {
      VectorSearchOperation vectorSearchOperation = VectorSearchOperation.search(config.vectorIndexName())
            .path(config.vectorField())
            .vector(embeddingService.embedQuery(req.query()))
            .limit(config.topK())
            .numCandidates(config.numCandidates());

      return mongoTemplate.aggregate(
            Aggregation.newAggregation(vectorSearchOperation),
            config.vectorCollectionName(),
            Movie.class
      ).getMappedResults();
   }
}
Enter fullscreen mode Exit fullscreen mode

The searchMovies method takes the user’s text, generates an embedding with EmbeddingService, and uses Spring Data’s new VectorSearchOperation to query MongoDB Atlas Vector Search, returning the most relevant movies directly as mapped Movie objects.

The MovieController

With everything in place, the last step is to expose our API through a simple controller. This class wires the MovieService and makes the /movies/search endpoint available:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;

@RestController
@RequestMapping("/movies")
public class MovieController {

   private final MovieService movieService;

   public MovieController(MovieService movieService) {
      this.movieService = movieService;
   }

   @PostMapping("/search")
   public ResponseEntity<List<Movie>> searchMovies(@RequestBody MovieSearchRequest req) {
      return ResponseEntity.ok(movieService.searchMovies(req));
   }
}
Enter fullscreen mode Exit fullscreen mode

In short: The controller takes in a search request, delegates to MovieService, and returns a list of matching movies.

By the end, you’ll have a project structure similar to this.

spring-data-mongodb-hybrid-search/
├── .idea/
├── .mvn/
└── src/
   └── main/
       ├── java/
       │   └── com/
       │       └── mongodb/
       │           ├── EmbeddingService.java
       │           ├── EmbeddingsRequest.java
       │           ├── EmbeddingsResponse.java
       │           ├── Movie.java
       │           ├── MovieController.java
       │           ├── MovieSearchRequest.java
       │           ├── MovieService.java
       │           ├── SpringDataMongodbHybridSearchApplication.java
       │           ├── VoyageClientConfig.java
       │           ├── VoyageConfigProperties.java
       │           └── VoyageEmbeddingsClient.java
       └── resources/
           ├── static/
           ├── templates/
           └── application.yml
Enter fullscreen mode Exit fullscreen mode

Note: There’s no strict separation into layers or packages here, since that’s not the focus of this tutorial. If you’re interested in a deeper dive into architecture, you can check out my article Clean and Modular Java: A Hexagonal Architecture Approach.

Running the application

Set the required environment variables:

export MONGODB_URI=<YOUR CONNECTION>
export VOYAGE_API_KEY=<YOUR API KEY>
Enter fullscreen mode Exit fullscreen mode

Then, start the application:

mvn spring-boot:run
Enter fullscreen mode Exit fullscreen mode

With the app running, let’s perform a POST request to our new endpoint:

Example request

### Searching movies
POST http://localhost:8080/movies/search
Content-Type: application/json

{
 "query": "a science fiction movie about rebels fighting an empire in space"
}
Enter fullscreen mode Exit fullscreen mode

You should see results coming back from the embedded_movies collection, movies semantically close to the description, even though the exact title wasn’t mentioned.

{
 "title": "Star Wars: Episode IV - A New Hope",
 "year": "1977",
 "fullplot": "A young boy from Tatooine..",
 "plot": "Luke Skywalker joins ..",
 "imdb": {
     "rating": 8.7
   },
 "genres": [
     "Action",
     "Adventure",
     "Fantasy"
  ],
 ...
}  
Enter fullscreen mode Exit fullscreen mode

Looking ahead

In this first part, we explored what vector search is, its core principles, and how it enables semantic search beyond simple keywords. We saw how to generate embeddings with Voyage AI, create a vector index in MongoDB Atlas, and use the brand-new Spring Data MongoDB support for vector queries to build a working movie search application.

If you’d like to check out the full project code, you can find it on GitHub.

Stay tuned for Part 2: Beyond Keywords: Optimizing Vector Search with Filters and Caching, where we’ll enhance this application by adding filters to our vector search, exploring how they work under the hood, and refining the overall search experience. (Coming soon!)

Top comments (0)