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.
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:
- 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.
- Convert the user query: When a user types a search, the query is also transformed into an embedding by the same model.
- 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:
- A MongoDB Atlas account
- Java 21+ installed and an IDE of your choice
- A Voyage AI API token
- The sample dataset uploaded to your cluster
- The embedded_movies collection, which we’ll query throughout the examples
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:
-
Generate embeddings: Each movie’s plot was sent to voyage-3-large, and the returned vector was stored in the
embedded_movies
collection asplot_embedding_voyage_3_large
. (This preprocessing is already done.)
- 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 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.
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"
}]}
)
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.
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
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) {}
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) {}
}
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
) {}
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
) {}
EmbeddingsResponse: This represents the response from Voyage AI.
import java.util.List;
public record EmbeddingsResponse(List<Item> data) {
public record Item(List<Double> embedding) {}
}
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);
}
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);
}
}
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();
}
}
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();
}
}
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));
}
}
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
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>
Then, start the application:
mvn spring-boot:run
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"
}
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"
],
...
}
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)