Building a Mood-Based Movie Recommendation Engine with Voyage-4-nano, Hugging Face, and MongoDB Atlas Vector Search

Community Article Published February 8, 2026

Traditional movie search relies on filtering by genre, actor, or title. But what if you could search by how you feel? Imagine typing:

  • "something uplifting after a rough day at work"
  • "a movie that will make me cry"
  • "I need adrenaline, can't sleep anyway"
  • "something to watch with grandma who hates violence"

This is mood-based semantic search: matching your emotional state to movie plot descriptions using AI embeddings.

In this tutorial, you will build a mood-based movie recommendation engine using three powerful technologies: Voyage-4-nano (a state-of-the-art open-source embedding model), Hugging Face (for model and dataset hosting), and MongoDB Atlas Vector Search (for storing and querying embeddings at scale).

Why Mood-Based Search?

Genre tags are coarse. A "drama" can be heartwarming or devastating. A "comedy" can be light escapism or dark satire. Traditional filters cannot capture these nuances.

Semantic search solves this by understanding meaning. When you search for "feel-good movie for a rainy Sunday", the system doesn't look for those exact words. It understands the intent and matches it against plot descriptions that evoke similar feelings.

Architecture Overview

The system combines three components from the Hugging Face ecosystem with MongoDB:

  • Voyage-4-nano (Hugging Face Hub): Converts text to embeddings (up to 2048 dimensions, we use 1024)
  • MongoDB/embedded_movies (Hugging Face Datasets): 1500+ movies with plot summaries, genres, cast image
  • MongoDB Atlas Vector Search: Stores embeddings and performs similarity search

Understanding Voyage-4-nano

Voyage-4-nano is the smallest model in Voyage AI's latest embedding series, released as open-source under the Apache 2.0 license. Voyage AI was acquired by MongoDB, and the Voyage 4 series models are now available through MongoDB Atlas.

All models in the series (voyage-4-large, voyage-4, voyage-4-lite, and voyage-4-nano) produce compatible embeddings in a shared embedding space, allowing you to mix and match models for different use cases.

Although Voyage-4-nano natively supports embeddings up to 2048 dimensions, we deliberately truncate them to 1024 dimensions using its Matryoshka embedding property. In practice, this provides a strong balance between semantic quality, storage efficiency, and vector search latency, while preserving stable ranking behavior.

Sentence Transformers

This tutorial uses Sentence Transformers, a Python library built on top of Hugging Face Transformers. It is specifically designed for working with embedding models and provides a simple API for generating text embeddings.

Why Sentence Transformers instead of raw Transformers? When working with embedding models, you need to handle tokenization, pooling, normalization, and prompt formatting. Sentence Transformers does all of this automatically in a single method call. The code is cleaner, there are fewer potential errors, and you get built-in features like batch processing with progress bars.

Under the hood, Sentence Transformers still uses Hugging Face Transformers to load and run the model.

Configure the Development Environment

Let's get started!

Create the Project Structure

mkdir mood-movie-search
cd mood-movie-search
mkdir src
touch requirements.txt .env

Install Dependencies

Create the requirements.txt file:

cat <<EOF > requirements.txt
fastapi>=0.109.0
uvicorn>=0.27.0
pymongo>=4.6.1
sentence-transformers>=3.0.0
python-dotenv>=1.0.0
datasets>=2.16.0
torch
EOF

Create a Python virtual environment and install dependencies:

python -m venv venv
source venv/bin/activate
pip install -r requirements.txt

Run MongoDB Atlas Locally

Use Atlas CLI to create a local deployment with Vector Search support:

brew install mongodb-atlas-cli
atlas deployments setup

Choose local when prompted. Once ready, you'll receive a connection string like:

Deployment created!
Connection string: 
"mongodb://localhost:52955/?directConnection=true"

Configure Environment Variables

Create the .env file:

cat <<EOF > .env
MONGO_URI=mongodb://localhost:52955/?directConnection=true
DB_NAME=movies_db
COLLECTION_NAME=movies
MODEL_NAME=voyageai/voyage-4-nano
EMBEDDING_DIM=1024
EOF

I use 1024 dimensions as a balance between quality and storage efficiency. You can experiment with 2048 (max quality) or 512 (faster queries).

Implement System Components

Database Module

The database module manages MongoDB connections and creates the vector search index. The index must match the embedding dimensions specified in our configuration.

# src/database.py
import os
from pymongo import MongoClient
from pymongo.operations import SearchIndexModel
from dotenv import load_dotenv

load_dotenv()


class MongoManager:
    def __init__(self):
        self.client = MongoClient(os.getenv("MONGO_URI"))
        self.db = self.client[os.getenv("DB_NAME")]
        self.collection = self.db[os.getenv("COLLECTION_NAME")]
        self.embedding_dim = int(os.getenv("EMBEDDING_DIM", 1024))
    
    def get_collection(self):
        return self.collection
    
    def create_vector_index(self):
        """Creates a Vector Search index for mood-based queries"""
        search_index_model = SearchIndexModel(
            definition={
                "fields": [
                    {
                        "type": "vector",
                        "path": "plot_embedding",
                        "numDimensions": self.embedding_dim,
                        "similarity": "cosine"
                    },
                    {
                        "type": "filter",
                        "path": "genres"
                    },
                    {
                        "type": "filter", 
                        "path": "year"
                    }
                ]
            },
            name="mood_search_index",
            type="vectorSearch"
        )
        try:
            self.collection.create_search_index(model=search_index_model)
            print(f"Vector index created with {self.embedding_dim} dimensions.")
        except Exception as e:
            print(f"Index status: {e}")

AI Module with Voyage-4-nano

The AI module loads Voyage-4-nano from Hugging Face Hub and provides methods for generating embeddings. We use the sentence-transformers library for a clean API.

# src/ai.py
import os
import torch
from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv

load_dotenv()


class MoodEmbedder:
    def __init__(self):
        model_name = os.getenv("MODEL_NAME", "voyageai/voyage-4-nano")
        self.embedding_dim = int(os.getenv("EMBEDDING_DIM", 1024))
        
        print(f"Loading {model_name} from Hugging Face Hub...")
        
        # Load with GPU optimization if available
        if torch.cuda.is_available():
            self.model = SentenceTransformer(
                model_name,
                trust_remote_code=True,
                truncate_dim=self.embedding_dim,
                model_kwargs={
                    "attn_implementation": "flash_attention_2",
                    "torch_dtype": torch.bfloat16
                }
            )
            print(f"Model loaded on GPU with Flash Attention 2")
        else:
            self.model = SentenceTransformer(
                model_name,
                trust_remote_code=True,
                truncate_dim=self.embedding_dim
            )
            print(f"Model loaded on CPU")
        
        print(f"Embedding dimension: {self.embedding_dim}")

    def embed_query(self, mood_description: str) -> list:
        """
        Embed a mood query. Uses query-specific prompt for better retrieval.
        Example: "something uplifting after a bad day"
        """
        if not mood_description:
            return []
        embedding = self.model.encode_query(mood_description)
        return embedding.tolist()

    def embed_document(self, plot: str) -> list:
        """
        Embed a movie plot description. Uses document-specific prompt.
        """
        if not plot:
            return []
        embedding = self.model.encode_document(plot)
        return embedding.tolist()
    
    def embed_documents_batch(self, plots: list[str], batch_size: int = 32) -> list:
        """
        Embed multiple plots efficiently in batches.
        """
        embeddings = self.model.encode_document(
            plots, 
            batch_size=batch_size,
            show_progress_bar=True
        )
        return [emb.tolist() for emb in embeddings]

Voyage-4-nano uses different prompts for queries and documents:

  • encode_query() prepends: "Represent the query for retrieving supporting documents:"
  • encode_document() prepends: "Represent the document for retrieval: "

This asymmetric encoding improves retrieval quality.

Data Indexing Module

The indexer downloads the movie dataset from Hugging Face, generates fresh embeddings with Voyage-4-nano, and stores everything in MongoDB.

# src/indexer.py
import os
from datasets import load_dataset
from src.database import MongoManager
from src.ai import MoodEmbedder

def extract_year(date_str):
    """Extract year from various date formats"""
    if not date_str:
        return None
    try:
        if isinstance(date_str, dict) and '$date' in date_str:
            from datetime import datetime
            ts = date_str['$date'].get('$numberLong', 0)
            return datetime.fromtimestamp(int(ts) / 1000).year
        return int(str(date_str)[:4])
    except:
        return None

def run_indexing():
    print("=" * 60)
    print("🎬 MOOD-BASED MOVIE SEARCH - INDEXING")
    print("=" * 60)

    print("\n[1/6] Connecting to MongoDB...")
    db = MongoManager()
    collection = db.get_collection()
    print("✓ Connected")

    print("\n[2/6] Loading Voyage-4-nano from Hugging Face Hub...")
    embedder = MoodEmbedder()
    print("✓ Model ready")

    print("\n[3/6] Downloading movie dataset from Hugging Face...")
    dataset = load_dataset("MongoDB/embedded_movies", split="train")
    print(f"✓ Downloaded {len(dataset)} movies")

    # Sample for demo (use full dataset in production)
    sample_size = min(500, len(dataset))
    sample_data = dataset.shuffle(seed=42).select(range(sample_size))
    print(f"\n[4/6] Processing {sample_size} movies...")

    collection.delete_many({})
    print("✓ Cleared existing data")

    # Prepare documents
    movies_to_embed = []
    plots_to_embed = []
    
    for item in sample_data:
        plot = item.get("plot", "")
        if not plot or len(plot) < 50:
            continue
            
        movie = {
            "title": item.get("title", "Unknown"),
            "plot": plot,
            "genres": item.get("genres", []),
            "year": extract_year(item.get("released")),
            "runtime": item.get("runtime"),
            "cast": item.get("cast", [])[:5],
            "directors": item.get("directors", []),
            "imdb_rating": item.get("imdb", {}).get("rating") if isinstance(item.get("imdb"), dict) else None,
        }
        movies_to_embed.append(movie)
        plots_to_embed.append(plot)

    print(f"  Found {len(movies_to_embed)} movies with valid plots")

    print("\n[5/6] Generating embeddings with Voyage-4-nano...")
    embeddings = embedder.embed_documents_batch(plots_to_embed)
    
    for movie, embedding in zip(movies_to_embed, embeddings):
        movie["plot_embedding"] = embedding

    if movies_to_embed:
        print(f"\n  Inserting {len(movies_to_embed)} movies into MongoDB...")
        collection.insert_many(movies_to_embed)
        print(f"✓ Inserted {len(movies_to_embed)} movies")

    print("\n[6/6] Creating vector search index...")
    db.create_vector_index()

    print("\n" + "=" * 60)
    print("✓ INDEXING COMPLETE!")
    print(f"  Movies indexed: {len(movies_to_embed)}")
    print(f"  Embedding dimensions: {embedder.embedding_dim}")
    print("=" * 60)


if __name__ == "__main__":
    run_indexing()

Search API

The FastAPI application exposes the mood-based search endpoint. Users describe their mood, and the system returns semantically matching movies.

# src/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, field_validator
from typing import Optional
from src.database import MongoManager
from src.ai import MoodEmbedder

app = FastAPI(title="Mood-Based Movie Search API")

# Initialize components
db = MongoManager()
embedder = MoodEmbedder()
collection = db.get_collection()


class MoodSearchRequest(BaseModel):
    mood: str
    limit: int = 5
    min_rating: Optional[float] = None
    genre_filter: Optional[str] = None

    @field_validator('mood')
    @classmethod
    def mood_must_not_be_empty(cls, v):
        if not v or not v.strip():
            raise ValueError('Mood description cannot be empty')
        return v.strip()

    @field_validator('limit')
    @classmethod
    def limit_must_be_reasonable(cls, v):
        if v < 1 or v > 20:
            raise ValueError('Limit must be between 1 and 20')
        return v


@app.post("/search")
def search_by_mood(request: MoodSearchRequest):
    """
    Search for movies based on your current mood.
    
    Examples:
    - "I need something uplifting after a rough day"
    - "want to cry and feel cathartic"
    - "exciting adventure for movie night with friends"
    - "something calm and beautiful to fall asleep to"
    """
    
    query_embedding = embedder.embed_query(request.mood)
    
    if not query_embedding:
        raise HTTPException(status_code=400, detail="Failed to process mood description")

    vector_search_stage = {
        "$vectorSearch": {
            "index": "mood_search_index",
            "path": "plot_embedding",
            "queryVector": query_embedding,
            "numCandidates": 150,
            "limit": request.limit * 2
        }
    }
    
    match_stage = {"$match": {}}
    if request.min_rating:
        match_stage["$match"]["imdb_rating"] = {"$gte": request.min_rating}
    if request.genre_filter:
        match_stage["$match"]["genres"] = request.genre_filter
    
    project_stage = {
        "$project": {
            "_id": 0,
            "title": 1,
            "plot": 1,
            "genres": 1,
            "year": 1,
            "imdb_rating": 1,
            "cast": 1,
            "directors": 1,
            "mood_match_score": {"$meta": "vectorSearchScore"}
        }
    }
    
    pipeline = [vector_search_stage]
    if match_stage["$match"]:
        pipeline.append(match_stage)
    pipeline.append(project_stage)
    pipeline.append({"$limit": request.limit})

    try:
        results = list(collection.aggregate(pipeline))
        
        return {
            "mood_query": request.mood,
            "results_count": len(results),
            "movies": results
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")


@app.get("/examples")
def get_example_queries():
    """Returns example mood queries to try"""
    return {
        "examples": [
            {"mood": "something uplifting after a rough day at work", "category": "Feel Good"},
            {"mood": "I want to cry and have a good emotional release", "category": "Emotional"},
            {"mood": "edge-of-seat thriller I can't pause", "category": "Exciting"},
            {"mood": "slow beautiful film to relax to", "category": "Calm"},
            {"mood": "something to watch with kids on Sunday", "category": "Family"},
            {"mood": "mind-bending movie that makes you think", "category": "Thought-provoking"},
            {"mood": "nostalgic 80s vibe adventure", "category": "Nostalgic"},
            {"mood": "dark and disturbing but artistic", "category": "Art House"},
        ]
    }


@app.get("/")
def health_check():
    return {
        "status": "ok",
        "service": "Mood-Based Movie Search API",
        "model": "voyage-4-nano",
        "embedding_dim": embedder.embedding_dim
    }

Package Initialization

touch src/__init__.py

Project Structure

Your final project structure should look like this:

mood-movie-search/
├── src/
│   ├── __init__.py
│   ├── ai.py           # Voyage-4-nano embedding logic
│   ├── database.py     # MongoDB connection and indexing
│   ├── indexer.py      # Data loading and embedding generation
│   └── main.py         # FastAPI application
├── requirements.txt
├── .env
└── venv/

Run and Test

Index the Data

First, run the indexer to download movies from Hugging Face and generate embeddings:

python -m src.indexer

Expected output:

=======================================
🎬 MOOD-BASED MOVIE SEARCH - INDEXING
=======================================

[1/6] Connecting to MongoDB...
✓ Connected

[2/6] Loading Voyage-4-nano from Hugging Face Hub...
Loading voyageai/voyage-4-nano from Hugging Face Hub...
Model loaded on CPU
Embedding dimension: 1024
✓ Model ready

[3/6] Downloading movie dataset from Hugging Face...
✓ Downloaded 1525 movies

[4/6] Processing 500 movies...
✓ Cleared existing data
  Found 487 movies with valid plots

[5/6] Generating embeddings
with Voyage-4-nano...
Batches: 100%|██████████████████|

  Inserting 487 movies into MongoDB...
✓ Inserted 487 movies

[6/6] Creating vector search index...
Vector index created with 1024 dimensions.

====================================
✓ INDEXING COMPLETE!
  Movies indexed: 487
  Embedding dimensions: 1024
====================================

Start the API Server

python -m uvicorn src.main:app --reload

Test Mood-Based Queries

Query 1: Feel-good movie after a tough day

curl -X POST "http://localhost:8000/search" \
     -H "Content-Type: application/json" \
     -d '{"mood": "something uplifting after a rough day at work"}'

Response:

{
  "mood_query": "something uplifting after a rough day at work",
  "results_count": 5,
  "movies": [
    {
      "title": "The Long Kiss Goodnight",
      "plot": "A woman suffering from amnesia begins to recover her memories...",
      "genres": ["Action", "Crime", "Drama"],
      "imdb_rating": 6.7,
      "mood_match_score": 0.662
    },
    {
      "title": "Villain",
      "plot": "Shiva a bus conductor has another face of life in which he helps the physically challenged...",
      "genres": ["Action", "Drama", "Romance"],
      "imdb_rating": 7.0,
      "mood_match_score": 0.659
    },
    {
      "title": "The Blade",
      "plot": "After the master of the Sharp Manufacturer saber factory abdicates...",
      "genres": ["Drama", "Action"],
      "imdb_rating": 7.2,
      "mood_match_score": 0.656
    }
  ]
}

The system returned action films with themes of personal transformation and helping others. Scores around 0.65 show moderate similarity because "uplifting" is an abstract concept that does not appear directly in plot text.

Query 2: Emotional catharsis

curl -X POST "http://localhost:8000/search" \
     -H "Content-Type: application/json" \
     -d '{"mood": "I want to cry and feel emotionally moved"}'

Response:

{
  "mood_query": "I want to cry and feel emotionally moved",
  "results_count": 5,
  "movies": [
    {
      "title": "The Wedding Party",
      "plot": "Screaming, shooting, tears and blood changes the party into a nightmare.",
      "genres": ["Action", "Comedy", "Drama"],
      "imdb_rating": 6.6,
      "mood_match_score": 0.666
    },
    {
      "title": "Split Decisions",
      "plot": "When a boxer is killed because he wouldn't take a dive, his brother tries to find a way to avenge him...",
      "genres": ["Action", "Drama"],
      "imdb_rating": 5.2,
      "mood_match_score": 0.625
    },
    {
      "title": "Tae Guk Gi: The Brotherhood of War",
      "plot": "A drama about the fate of brothers forced to fight in the Korean War.",
      "genres": ["Action", "Drama", "War"],
      "imdb_rating": 8.2,
      "mood_match_score": 0.618
    }
  ]
}

The system found films with emotionally heavy themes. "Tae Guk Gi" is a war drama about brothers forced to fight against each other. "Split Decisions" deals with loss and revenge after a boxer's death. The word "tears" in "The Wedding Party" plot directly matched the query's emotional intent.

Query 3: With filters

curl -X POST "http://localhost:8000/search" \
     -H "Content-Type: application/json" \
     -d '{
       "mood": "exciting adventure movie",
       "min_rating": 7.5,
       "genre_filter": "Action"
     }'

Response:

{
  "mood_query": "exciting adventure movie",
  "results_count": 1,
  "movies": [
    {
      "title": "Project A",
      "plot": "Fighting against pirates in old Hong Kong. Chinese costume drama with plenty of over-the-top tongue in cheek action and music.",
      "genres": ["Action", "Comedy"],
      "cast": ["Jackie Chan", "Sammo Kam-Bo Hung"],
      "imdb_rating": 7.5,
      "mood_match_score": 0.761
    }
  ]
}

With strict filters (rating >= 7.5, genre = Action), only one movie matched. Notice the significantly higher score of 0.761 compared to previous queries. The phrase "exciting adventure" directly aligns with "over-the-top action" in the plot description. This demonstrates that concrete, descriptive mood queries produce stronger semantic matches than abstract emotional ones.

Key Observations

  • Abstract mood queries ("uplifting", "emotionally moved") score 0.62 to 0.67
  • Concrete descriptions ("exciting adventure") score 0.75+
  • Filters help find better matches even with fewer results
  • The system understands themes even without exact word matches

Comparing Embedding Dimensions

One unique advantage of Voyage-4-nano's Matryoshka embeddings is the ability to experiment with different dimensions. Let's compare how dimension choice affects results.

Create a test script:

# test_dimensions.py
from sentence_transformers import SentenceTransformer
import numpy as np

model_2048 = SentenceTransformer("voyageai/voyage-4-nano", trust_remote_code=True, truncate_dim=2048)
model_512 = SentenceTransformer("voyageai/voyage-4-nano", trust_remote_code=True, truncate_dim=512)
model_256 = SentenceTransformer("voyageai/voyage-4-nano", trust_remote_code=True, truncate_dim=256)

query = "heartwarming story about friendship"
plots = [
    "Two elderly men rediscover their friendship during a road trip across America.",
    "A violent revenge thriller set in the criminal underworld.",
    "A young girl and her robot companion explore a magical forest.",
]

for dim, model in [(2048, model_2048), (512, model_512), (256, model_256)]:
    q_emb = model.encode_query(query)
    d_embs = model.encode_document(plots)
    
    similarities = np.dot(d_embs, q_emb) / (np.linalg.norm(d_embs, axis=1) * np.linalg.norm(q_emb))
    
    print(f"\n=== {dim} dimensions ===")
    for plot, sim in zip(plots, similarities):
        print(f"  {sim:.4f}: {plot[:50]}...")

Run the script:

python test_dimensions.py

Result:

=== 2048 dimensions ===
  0.4496: Two elderly men rediscover their friendship during...
  0.1472: A violent revenge thriller set in the criminal und...
  0.3726: A young girl and her robot companion explore a mag...

=== 512 dimensions ===
  0.4350: Two elderly men rediscover their friendship during...
  0.0953: A violent revenge thriller set in the criminal und...
  0.3487: A young girl and her robot companion explore a mag...

=== 256 dimensions ===
  0.4139: Two elderly men rediscover their friendship during...
  0.0168: A violent revenge thriller set in the criminal und...
  0.3310: A young girl and her robot companion explore a mag...

All three dimension settings correctly rank the plots in the same order: the friendship story scores highest, the robot companion story is second (thematically related through companionship), and the violent thriller scores lowest.

The key differences:

Discrimination gap: With 2048 dimensions, the gap between the best match (0.4496) and worst match (0.1472) is 0.302. With 256 dimensions, the gap is larger at 0.397 (0.4139 minus 0.0168). Interestingly, lower dimensions can produce wider gaps due to more aggressive compression of the embedding space, but this doesn't necessarily mean better quality. The absolute similarity scores are lower and less reliable.

Ranking stability: All dimensions produce the same ranking. For simple retrieval tasks, even 256 dimensions work well.

When dimensions matter: If your application needs fine-grained similarity scores, use higher dimensions. If you only need correct ranking, lower dimensions save storage and query time.

Practical recommendation: Start with 1024 dimensions as a balanced default. Drop to 512 if storage is a concern. Use 2048 only when you need maximum precision for nuanced queries.

Community

Sign up or log in to comment