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!
. 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 with pre-generated "vibe descriptions" and embeddings. You can download the seed files from the index:
CODEnpm run seed
This creates a new database called
song_vibe_searchand a new collection calledsongs. Thesongcollection contains 500 documents, each representing a song with a "vibe description" and an embedding vector. It also creates a vector search index on theembeddingfield by running the following code:
CODEawait collection.createSearchIndex({
name: VECTOR_INDEX_NAME,
type: "vectorSearch",
definition: {
fields: [
{
type: "vector",
path: "embedding",
numDimensions: 512,
similarity: "cosine",
},
],
},
});
This is what makes
$vectorSearchpossible. 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. . The$vectorSearchstage 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
$projectstage 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.tsin thesrcdirectory. 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 s
erver.ts, which specifies the imports and sets up an Express server:
CODEimport "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
searchSongsfunction we created earlier. Paste the following code intoserver.ts:
CODEapp.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:
CODEasync 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
publicin the root of your project (it's important to keep the name aspublicsince our server is already set up to serve files from this directory). Then, create a new file calledindex.htmlin that folder.
Paste the following code into
index.html:
CODE<!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.cssandapp.jsfiles. Create a new file calledstyle.cssin thepublicdirectory and paste the following code into it:
CODE*, *::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 calledapp.jsin thepublicdirectory and paste the following code into it:
CODEconst 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:
CODEnpm run dev
Open , you'll notice that there are a few files in the
seeddirectory that we didn't use in this tutorial. ThefetchTracks.tsfile 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.tsandgenerateEmbeddings.tsfiles contain the code used to generate the "vibe descriptions" and embeddings for the sample data. ThegenerateVibeDescriptions.tsfile uses the Gemini API to generate a text description of the "vibe" of each song. ThegenerateEmbeddings.tsfile 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 or leave a comment letting me know how you’d like to see this expanded on!
↗ Original-Artikel auf dev.to lesenVollständiger Original-BerichtAusführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
SOCIAL SHARE CARD GENERATOR