Have you ever found yourself in the mood for a certain type of music but not any specific artists, songs, or genres in mind? Maybe you just had a long day and are looking for something with mellow and relaxing vibes. Or maybe it's a Friday afternoon, and you're ready for something to pump you up for the weekend. In times like these, traditional search experiences won't work. But today, we're going to build something that will.
In a traditional search experience, you would search for a term and get back a list of songs or artists that contain keywords from that search term somewhere in their name or description. But that's not what we want here. What we actually want is semantic search: a system that understands what you mean, not just what you typed. That's exactly what we're building. This music recommender takes a natural-language description of the music's “vibe” and finds songs that match that feel. And it's all powered by MongoDB Vector Search and Voyage AI.
In this tutorial, you’ll learn how to build a working web application where you can type something like "upbeat road trip with the windows down" and get back a ranked list of songs that fit that feeling. Let's get into it!
How It Actually Works
Before we write any code, let's go through a quick overview of how the search functionality in this app actually works.
The key to all of this is vector embeddings. A vector embedding is a way of representing text (or other data like images or audio) as an array of numbers, or vectors. Data with similar semantic meanings produces similar vectors, so you can compare all of your vectors mathematically to find data that's similar in meaning.
Here's how it actually works in this app:
- Every song in our sample collection has an LLM-generated "vibe description". This is a 2-3 sentence description of what the song "feels" like to listen to. Vector embeddings were generated from these descriptions using Voyage AI and are stored with the songs.
- When a user types a search query, it is converted into a vector using the same Voyage AI vector embedding model.
- We use MongoDB Vector Search to find the songs in the collection whose vectors are closest to the query vector.
- The results are returned to the user, ranked by how closely their vector matches the query vector.
Full Project Code
If you want to see the full project with all of the code already written, you can find it on GitHub. I'll explain all the elements throughout this tutorial, and you can clone the repository and follow along as well.
Prerequisites
Before getting started, you'll need a few things in place:
- Node.js (v18 or later) installed on your machine
- A MongoDB Atlas account with a free cluster
- A Voyage AI API key - you can get one from the MongoDB Atlas UI
Project Setup
With that out of the way, let’s get going with the actual code.
Start by creating a new project directory and initializing it:
mkdir music-recommender
cd music-recommender
npm init -y
Then install the dependencies:
npm install express mongodb voyageai dotenv
Create a .env file in the root of your project with the following variables:
MONGODB_URI=<Your MongoDB Connection String>
MONGODB_DATABASE=song_vibe_search
VOYAGE_API_KEY=<Your Voyage AI API Key>
Next, you'll need to download the seed files. These files contain scripts that will initialize your database by using sample data. The scripts also contain code that you can use to generate your own descriptions and embeddings, but we won't get into that until the Deeper Dive: Generating Embeddings section at the end of the tutorial.
The sample data is a small sample of 500 songs from the Free Music Archive with pre-generated "vibe descriptions" and embeddings. You can download the seed files from the /seed directory in the GitHub repository for this tutorial.
After you have downloaded the seed folder, place it at the root of your project. Then run the following command to connect to your, insert the seed data, and create a vector search index:
npm run seed
This creates a new database called song_vibe_search and a new collection called songs. The song collection contains 500 documents, each representing a song with a "vibe description" and an embedding vector. It also creates a vector search index on the embedding field by running the following code:
await collection.createSearchIndex({
name: VECTOR_INDEX_NAME,
type: "vectorSearch",
definition: {
fields: [
{
type: "vector",
path: "embedding",
numDimensions: 512,
similarity: "cosine",
},
],
},
});
This is what makes $vectorSearch possible. We need to tell MongoDB the shape of our embedding field so it can build the right index structure.
The index contains the type of index (in our case, vector), the path to the field, and the following parameters that tell MongoDB how to compare vectors:
-
numDimensions: 512— This must match the output size of your embedding model. Voyage AI'svoyage-3-lite\model produces 512-dimensional vectors. If you're using a different model, you should be able to find the number of dimensions in the documentation for your model. -
similarity: "cosine"— This is the distance metric used to compare vectors. Cosine similarity works well for text embeddings because it measures the angle between vectors rather than their raw distance, which makes it robust to differences in text length.
Now that we have our data and our vector search index, we are ready to build the main application.
Implementing Vector Search
In the root of your project, create a new directory called src and create a new file called server.ts in that directory. This is where we will implement the search logic that powers our recommendations.
First, we need to set our imports, our interfaces and constants, and establish our connection to MongoDB. Paste the following code into server.ts:
import "dotenv/config";
import { MongoClient } from "mongodb";
import { VoyageAIClient } from "voyageai";
const MONGODB_URI = process.env.MONGODB_URI;
const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;
if (!MONGODB_URI) {
throw new Error(
"Missing MONGODB_URI in environment. Ensure that you have created a .env file and added your connection string."
);
}
if (!VOYAGE_API_KEY) {
throw new Error(
"Missing VOYAGE_API_KEY in environment. Ensure that you have created a .env file and added your Voyage AI key."
);
}
export const client = new MongoClient(MONGODB_URI, {
appName: "devrel-tutorial-javascript-music-recommender",
});
const voyage = new VoyageAIClient({ apiKey: VOYAGE_API_KEY });
const DB_NAME = process.env.MONGODB_DATABASE ?? "song_vibe_search";
const COLLECTION_NAME = "songs";
export const VECTOR_INDEX = "vibe_vector_index";
const EMBEDDING_MODEL = "voyage-3-lite";
export const NUM_RESULTS = 5;
export const NUM_CANDIDATES = 100;
export interface SearchResult {
title: string;
artist: string;
genre_top: string;
genres: string[];
score: number;
}
export interface SearchResponse {
results: SearchResult[];
queryVector: number[];
}
If you're unfamiliar with vector search, some of the constants above may not make sense quite yet, but we'll explain them more as they're used.
Embedding the query
When a user submits a search, the first thing we need to do is embed that query by using the same Voyage AI model that was used to embed our song descriptions. Paste the following code into server.ts to create a search function and embed the query. Note the comment that shows where we will add the vector search query code in a later step.
export async function searchSongs(query: string): Promise<SearchResponse> {
// Embed the user's query by using Voyage AI.
const embedResponse = await voyage.embed({
model: EMBEDDING_MODEL,
input: [query],
inputType: "query",
});
const queryVector = embedResponse.data![0].embedding!;
// vector search query code here
}
This code specifies a few things. First, we specify the embedding model to use (in our case, we pass in the constant that we set earlier). We then pass the actual user query as an array to the input parameter. Finally, we specify the inputType as "query". Voyage AI distinguishes between the following two types of input:
-
"document"— used when embedding data that gets saved into your database (the song vibe descriptions, in our case) -
"query"— used when embedding a search query at runtime
Using the right input type improves retrieval quality. It's a small but important thing to keep in mind when implementing vector search applications.
Now that we have our query embedded, it's time to use it to search for similar songs. Paste the following code to complete our search function. Ensure that this code is pasted inside the function that we defined in the previous code block.
// ... previous embedding code here
// vector search query code here
const collection = client.db(DB_NAME).collection(COLLECTION_NAME);
const results = await collection
.aggregate<SearchResult>([
{
$vectorSearch: {
index: VECTOR_INDEX,
path: "embedding",
queryVector,
numCandidates: NUM_CANDIDATES,
limit: NUM_RESULTS,
},
},
{
$project: {
_id: 0,
title: 1,
artist: 1,
genre_top: 1,
genres: 1,
score: { $meta: "vectorSearchScore" },
},
},
])
.toArray();
return { results, queryVector };
To implement vector search, we use the $vectorSearch stage of the aggregation pipeline. The $vectorSearch stage takes the following parameters:
-
index— The name of the vector search index we created during seeding -
path— The field in the document that holds the embedding (in this case, named"embedding"). -
queryVector— The vector we just generated from the user's query. -
numCandidates— How many candidate documents to consider before ranking. A higher number means the search will be more thorough but slower. -
limit— How many results to return.
After the search stage, a $project stage shapes our output, with { $meta: "vectorSearchScore" } pulling in the similarity score for each result — a number between 0 and 1, where higher means more similar.
And that's all we need for our search functionality! Next, we'll build out a simple server with an endpoint to call this function.
Building the Server
Create a new file called server.ts in the src directory. This is where we will create any endpoints we might need. In this tutorial, we will create a single endpoint to search for songs.
To start, paste the following code into server.ts, which specifies the imports and sets up an Express server:
import "dotenv/config";
import express from "express";
import path from "path";
import { client, searchSongs, VECTOR_INDEX, NUM_RESULTS, NUM_CANDIDATES } from "./search";
const app = express();
const PORT = process.env.PORT ?? 3000;
app.use(express.json());
app.use(express.static(path.join(__dirname, "../public")));
Now, we'll create our POST route that calls the searchSongs function we created earlier. Paste the following code into server.ts:
app.post("/api/search", async (req, res) => {
const { query } = req.body;
if (!query || typeof query !== "string" || query.trim() === "") {
res.status(400).json({ error: "A search query is required." });
return;
}
let results, queryVector;
try {
({ results, queryVector } = await searchSongs(query.trim()));
} catch (err) {
console.error("Search failed:", err);
res.status(500).json({ error: "Search failed. Please try again." });
return;
}
res.json({
results
});
});
In the preceding code, it first checks that the query is valid. Then it sends the query to our searchSongs() function and returns the results. If there is an error, it returns an error message.
The last thing we need to do is write the code that actually starts the server and handles signals to close the client cleanly on shutdown. Paste the following code into server.ts:
async function startServer() {
await client.connect();
console.log("Connected to MongoDB Atlas.");
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
process.on("SIGINT", async () => {
await client.close();
process.exit(0);
});
process.on("SIGTERM", async () => {
await client.close();
process.exit(0);
});
}
startServer();
Now that we have our server set up, let's create a simple front end to interact with it.
Building the Front End
Since the purpose of this tutorial is the search functionality itself, we'll keep the front end simple. Create a new directory called public in the root of your project (it's important to keep the name as public since our server is already set up to serve files from this directory). Then, create a new file called index.html in that folder.
Paste the following code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vibe Search</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="container">
<header>
<h1>Vibe Search</h1>
<p>Describe a mood or feeling and we'll find songs that match.</p>
</header>
<form id="search-form">
<input
id="query-input"
type="text"
placeholder="e.g. melancholy rainy afternoon, upbeat road trip..."
autocomplete="off"
/>
<button type="submit">Search</button>
</form>
<p class="token-note">Note: Each search generates a Voyage AI embedding and consumes tokens.</p>
<div id="results"></div>
</div>
<script src="app.js"></script>
</body>
</html>
This HTML creates a simple page with a header, an input box, and a section for results.
Now we need to create the style.css and app.js files. Create a new file called style.css in the public directory and paste the following code into it:
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f0f;
color: #e8e8e8;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.container {
width: 100%;
max-width: 720px;
}
header {
text-align: center;
margin-bottom: 2.5rem;
}
header h1 {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 0.5rem;
}
header p {
color: #777;
font-size: 1rem;
}
/* ── Search form ── */
#search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 2.5rem;
}
#query-input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
color: #e8e8e8;
outline: none;
}
#query-input:focus {
border-color: #555;
}
#query-input::placeholder {
color: #555;
}
#search-form button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
background: #00ed64;
color: #000;
border: none;
border-radius: 8px;
cursor: pointer;
}
#search-form button:hover {
background: #00c853;
}
/* ── Token note ── */
.token-note {
font-size: 1rem;
color: #989898;
text-align: center;
margin-top: 0.6rem;
margin-bottom: 0;
}
/* ── Spinner ── */
.spinner {
width: 28px;
height: 28px;
border: 3px solid #2a2a2a;
border-top-color: #00ed64;
border-radius: 50%;
margin: 2rem auto;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Result cards ── */
#results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
overflow: hidden;
}
.card-color-bar {
height: 6px;
}
.card-body {
padding: 1.25rem 1.5rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.2rem;
}
.card-artist {
color: #777;
font-size: 0.9rem;
margin-bottom: 0.85rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 1rem;
}
.tag {
font-size: 0.75rem;
padding: 0.2rem 0.65rem;
border-radius: 999px;
background: #252525;
color: #999;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.score {
font-size: 0.8rem;
color: #555;
}
.fma-link {
font-size: 0.8rem;
color: #00ed64;
text-decoration: none;
}
.fma-link:hover {
text-decoration: underline;
}
.message {
text-align: center;
color: #555;
padding: 2rem 0;
font-size: 0.95rem;
}
.message.error {
color: #e05252;
}
The CSS added here is outside of the scope of this tutorial, but it just makes the app easier to look at and use.
The last file we need to create is app.js, which contains the front-end JavaScript code. Create a new file called app.js in the public directory and paste the following code into it:
const DEFAULT_GRADIENT = "linear-gradient(135deg, #434343, #1a1a1a)";
function renderResults(results) {
const container = document.getElementById("results");
if (results.length === 0) {
container.innerHTML = '<p class="message">No results found. Try a different vibe.</p>';
return;
}
container.innerHTML = results.map((result) => {
const score = Math.round(result.score * 100);
const tags = result.genres.map((g) => `<span class="tag">${g}</span>`).join("");
return `
<div class="card">
<div class="card-color-bar" style="background: ${DEFAULT_GRADIENT}"></div>
<div class="card-body">
<div class="card-title">${result.title}</div>
<div class="card-artist">${result.artist}</div>
<div class="tags">${tags}</div>
<div class="card-footer">
<span class="score">Match: ${score}%</span>
<a class="fma-link" href="https://www.youtube.com/results?search_query=${encodeURIComponent(result.title + ' ' + result.artist)}" target="_blank" rel="noopener">Find on YouTube →</a>
</div>
</div>
</div>
`;
}).join("");
}
document.getElementById("search-form").addEventListener("submit", async (e) => {
e.preventDefault();
const query = document.getElementById("query-input").value.trim();
if (!query) return;
const container = document.getElementById("results");
container.innerHTML = '<div class="spinner"></div>';
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!response.ok) {
container.innerHTML = '<p class="message error">Something went wrong. Please try again.</p>';
return;
}
const { results } = await response.json();
renderResults(results);
});
This JavaScript code adds interactivity to the page. It sends the search query to the server and displays the search results.
Running the App
At this point, you should have everything set up. Make sure you have already run the npm run seed\ command from earlier in the tutorial.
Then, run the following command to start the server:
npm run dev
Open http://localhost:3000 and try a few searches, such as:
- "melancholy rainy afternoon"
- "high energy workout, aggressive beats"
- "peaceful background music for focusing"
You should see the results ranked by similarity score, with a link to YouTube so you can listen to the songs (if they're uploaded there). In a real application, you would want to serve the audio files or link directly to a streaming service, but for the sample data we're using here, searching YouTube is the only free option without license limitations. If you want to expand on this with different music, check out APIs for streaming services like Spotify.
Next Steps
And that's the basics! You've now built a working music recommender that uses MongoDB Vector Search and Voyage AI to find songs based on their "vibe". There are lots of ways you can expand on this as well. You could modify this to use music from different sources, expand the search functionality to include other metadata or filtering options, or even implement user profiles so that you can create personalized playlists and recommendations based on their likes, dislikes, and listening habits.
In the last section of this tutorial, we'll go through how the data for this tutorial was embedded. This can be useful if you're interested in adapting this tutorial to use your own data, or if you just want to learn more about working with vector embeddings.
Deeper Dive: Generating Embeddings {#deeper-dive:-generating-embeddings}
If you look at the GitHub repo, you'll notice that there are a few files in the seed directory that we didn't use in this tutorial. The fetchTracks.ts file contains code for downloading metadata about the songs that were downloaded from the Free Music Archive. This file will be specific to the Free Music Archive data, so we won't go into depth on it here.
The generateVibeDescriptions.ts and generateEmbeddings.ts files contain the code used to generate the "vibe descriptions" and embeddings for the sample data. The generateVibeDescriptions.ts file uses the Gemini API to generate a text description of the "vibe" of each song. The generateEmbeddings.ts file uses the Voyage AI API to generate an embedding for each description.
Generating Vibe Descriptions
Before generating embeddings from our data, we need some kind of description to embed. Raw metadata like title, artist, and genre isn't rich enough to embed meaningfully on its own, so we use Google Gemini to generate a 2-3 sentence vibe description for each track. Then, we save these descriptions in a new field in our documents called vibe_description.
To run the code to generate the descriptions, you'll need to get a Gemini API key. You can do this by going to the AI Studio and creating a new project. Then, add your API key to the .env file:
GEMINI_API_KEY=<Your Gemini API Key>
Then, create a file in the seed directory called generateVibeDescriptions.ts and paste the following code into it:
import "dotenv/config";
import { GoogleGenAI } from "@google/genai";
import type { RawTrack } from "./fetchTracks";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
const GEMINI_MODEL = "gemini-3-flash-preview";
const BATCH_SIZE = 50;
export interface TrackWithVibe extends RawTrack {
vibe_description: string;
}
function buildPrompt(tracks: RawTrack[]): string {
const trackList = tracks
.map(
(t, i) =>
`${i + 1}. Title: "${t.title}" | Artist: "${t.artist}" | Album: "${t.album}" | Genres: ${t.genres.join(", ")}`
)
.join("\n");
return `For each track listed below, write a 2-3 sentence vibe description in third person. Focus on mood, energy, atmosphere, and ideal listening context. Avoid simply restating the genre — describe how it feels to listen to it. Return a JSON array of strings, one description per track, in the same order as the input. Return only the JSON array with no additional text. Tracks: ${trackList}`;
}
function parseResponse(text: string): string[] {
// Extract the JSON array by finding the outermost [ ... ] bounds.
// This handles cases where the model adds explanatory text before or after the JSON.
const start = text.indexOf("[");
const end = text.lastIndexOf("]");
if (start === -1 || end === -1 || end < start) {
throw new Error(`No JSON array found in model response:\n${text}`);
}
return JSON.parse(text.slice(start, end + 1));
}
export async function generateVibeDescriptions(
tracks: RawTrack[]
): Promise<TrackWithVibe[]> {
if (!GEMINI_API_KEY) {
throw new Error(
"GEMINI_API_KEY is not set. Add it to your .env file.\n" +
"Get a free key at: https://aistudio.google.com/apikey"
);
}
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const results: TrackWithVibe[] = [];
const totalBatches = Math.ceil(tracks.length / BATCH_SIZE);
console.log(`\nGenerating vibe descriptions via Gemini (${totalBatches} batches)...`);
for (let i = 0; i < tracks.length; i += BATCH_SIZE) {
const batch = tracks.slice(i, i + BATCH_SIZE);
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
console.log(` Batch ${batchNum}/${totalBatches}`);
const response = await ai.models.generateContent({
model: GEMINI_MODEL,
contents: buildPrompt(batch),
});
const descriptions = parseResponse(response.text ?? "");
if (descriptions.length !== batch.length) {
throw new Error(
`Batch ${batchNum}: expected ${batch.length} descriptions, got ${descriptions.length}`
);
}
for (let j = 0; j < batch.length; j++) {
results.push({ ...batch[j], vibe_description: descriptions[j] });
}
}
console.log(` ${results.length} vibe descriptions generated.`);
return results;
}
In this code, the first function creates the prompt that we send to Gemini. The prompt is a list of all the tracks we want to generate descriptions for, along with instructions for how we want the descriptions formatted.
Then, the generateVibeDescriptions function takes our tracks and sends them to Gemini along with that prompt. Gemini will respond with a JSON array of strings, each of which is a vibe description for a track. We parse the response (and strip any extraneous text that Gemini might have added) and return the tracks with their new descriptions.
Generating Embeddings
Now that we have our vibe descriptions, we can generate embeddings for them. To do this, we use the same Voyage AI model that we used to embed our search queries earlier in the tutorial. This process is very similar to the process that we use to embed our search queries.
Create a file in the seed directory called generateEmbeddings.ts and paste the following code into it:
import "dotenv/config";
import { VoyageAIClient } from "voyageai";
import type { TrackWithVibe } from "./generateVibeDescriptions";
const EMBEDDING_MODEL = "voyage-3-lite";
export interface TrackWithEmbedding extends TrackWithVibe {
embedding: number[];
}
export async function generateEmbeddings(
tracks: TrackWithVibe[]
): Promise<TrackWithEmbedding[]> {
const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;
if (!VOYAGE_API_KEY) {
throw new Error("Missing VOYAGE_API_KEY in environment");
}
const voyage = new VoyageAIClient({ apiKey: VOYAGE_API_KEY });
console.log(`\nGenerating embeddings for ${tracks.length} tracks...`);
const response = await voyage.embed({
model: EMBEDDING_MODEL,
input: tracks.map((t) => t.vibe_description),
inputType: "document",
});
const results: TrackWithEmbedding[] = [];
for (let i = 0; i < tracks.length; i++) {
const embeddingData = response.data?.[i];
if (!embeddingData?.embedding) {
console.warn(` Warning: no embedding returned for "${tracks[i].title}" — skipping`);
continue;
}
results.push({ ...tracks[i], embedding: embeddingData.embedding });
}
console.log(` ${results.length} embeddings generated.`);
return results;
}
The main difference in embedding this data is that we specify the input type as "document" rather than "query". This distinction is important when embedding data with Voyage AI, as it embeds the data slightly differently depending on the type you specify.
Putting It All Together
Now that we have the code to generate descriptions and embeddings, we need to run it when seeding our data. To do so, we just need to modify the index.ts file in the seed directory and call the functions before inserting the data into the database. It will look something like this:
const tracksWithVibes = await generateVibeDescriptions(rawTracks);
const tracksWithEmbeddings = await generateEmbeddings(tracksWithVibes);
const documents = tracksWithEmbeddings.map((track) => ({
...track,
seeded_at: new Date(),
}));
console.log(`\nInserting ${documents.length} documents into MongoDB...`);
await collection.insertMany(documents);
Note that you will likely have to modify the code you created earlier so that the flow of the seed script doesn't insert multiple times (since we originally set it up to use pre-built data).
Now, run the seed command again, and you should see the script generate descriptions and embeddings for the data before inserting it into the database. Then you can run the app again to try searching through the data.
Conclusion
You have now built a fully functional application that uses MongoDB Vector Search and Voyage AI to recommend music based on their general “vibes.” While this app is pretty simple and provides only basic functionality, you can easily use it as a baseline to build much more sophisticated applications.
Hopefully, this gives you some insight into just how powerful MongoDB Vector Search can be. If you want to learn more, check out our documentation or leave a comment letting me know how you’d like to see this expanded on!

Top comments (0)