{"slug": "building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai", "title": "Building a Vibe-Based Music Recommender with MongoDB and Voyage AI", "summary": "A developer built a vibe-based music recommender using MongoDB Vector Search and Voyage AI. The application takes natural-language descriptions of music moods and returns semantically similar songs from a dataset of 500 tracks from the Free Music Archive. The project is available on GitHub and demonstrates semantic search with vector embeddings.", "body_md": "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.\n\nIn 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.\n\nIn 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!\n\nBefore we write any code, let's go through a quick overview of how the search functionality in this app actually works.\n\nThe 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.\n\nHere's how it actually works in this app:\n\nIf you want to see the full project with all of the code already written, you can find it on [GitHub](https://github.com/mongodb-developer/song-vibe-recommendations). I'll explain all the elements throughout this tutorial, and you can clone the repository and follow along as well.\n\nBefore getting started, you'll need a few things in place:\n\nWith that out of the way, let’s get going with the actual code.\n\nStart by creating a new project directory and initializing it:\n\n```\nmkdir music-recommender\ncd music-recommender\nnpm init -y\n```\n\nThen install the dependencies:\n\n```\nnpm install express mongodb voyageai dotenv\n```\n\nCreate a `.env`\n\nfile in the root of your project with the following variables:\n\n```\nMONGODB_URI=<Your MongoDB Connection String>\nMONGODB_DATABASE=song_vibe_search\n\nVOYAGE_API_KEY=<Your Voyage AI API Key>\n```\n\nNext, 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.\n\nThe sample data is a small sample of 500 songs from the [Free Music Archive](https://freemusicarchive.org/) with pre-generated \"vibe descriptions\" and embeddings. You can download the seed files from the [/seed directory](https://github.com/mongodb-developer/song-vibe-recommendations/tree/main/seed) in the GitHub repository for this tutorial.\n\nAfter 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](https://www.mongodb.com/resources/basics/vector-search/?utm_campaign=devrel&utm_source=Third+Party+Content&utm_medium=Call+to+Action+link&utm_content=mongodb-blog-publication-dev.to&utm_term=marissa.doherty) index:\n\n```\nnpm run seed\n```\n\nThis creates a new database called `song_vibe_search`\n\nand a new collection called `songs`\n\n. The `song`\n\ncollection 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`\n\nfield by running the following code:\n\n```\nawait collection.createSearchIndex({\n    name: VECTOR_INDEX_NAME,\n    type: \"vectorSearch\",\n    definition: {\n    fields: [\n        {\n        type: \"vector\",\n        path: \"embedding\",\n        numDimensions: 512,\n        similarity: \"cosine\",\n        },\n    ],\n    },\n});\n```\n\nThis is what makes `$vectorSearch`\n\npossible. We need to tell MongoDB the shape of our embedding field so it can build the right index structure.\n\nThe 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:\n\n`numDimensions: 512`\n\n— This must match the output size of your embedding model. `voyage-3-lite\\`\n\nmodel 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.\n`similarity: \"cosine\"`\n\n— 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.\n\nIn the root of your project, create a new directory called `src`\n\nand create a new file called `server.ts`\n\nin that directory. This is where we will implement the search logic that powers our recommendations.\n\nFirst, we need to set our imports, our interfaces and constants, and establish our connection to MongoDB. Paste the following code into `server.ts`\n\n:\n\n``` js\nimport \"dotenv/config\";\nimport { MongoClient } from \"mongodb\";\nimport { VoyageAIClient } from \"voyageai\";\n\nconst MONGODB_URI = process.env.MONGODB_URI;\nconst VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;\n\nif (!MONGODB_URI) {\n  throw new Error(\n    \"Missing MONGODB_URI in environment. Ensure that you have created a .env file and added your connection string.\"\n  );\n}\n\nif (!VOYAGE_API_KEY) {\n  throw new Error(\n    \"Missing VOYAGE_API_KEY in environment. Ensure that you have created a .env file and added your Voyage AI key.\"\n  );\n}\n\nexport const client = new MongoClient(MONGODB_URI, {\n  appName: \"devrel-tutorial-javascript-music-recommender\",\n});\n\nconst voyage = new VoyageAIClient({ apiKey: VOYAGE_API_KEY });\n\nconst DB_NAME = process.env.MONGODB_DATABASE ?? \"song_vibe_search\";\nconst COLLECTION_NAME = \"songs\";\nexport const VECTOR_INDEX = \"vibe_vector_index\";\nconst EMBEDDING_MODEL = \"voyage-3-lite\";\nexport const NUM_RESULTS = 5;\nexport const NUM_CANDIDATES = 100;\n\nexport interface SearchResult {\n  title: string;\n  artist: string;\n  genre_top: string;\n  genres: string[];\n  score: number;\n}\n\nexport interface SearchResponse {\n  results: SearchResult[];\n  queryVector: number[];\n}\n```\n\nIf 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.\n\nWhen 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`\n\nto 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.\n\n```\nexport async function searchSongs(query: string): Promise<SearchResponse> {\n\n  // Embed the user's query by using Voyage AI.\n  const embedResponse = await voyage.embed({\n    model: EMBEDDING_MODEL,\n    input: [query],\n    inputType: \"query\",\n  });\n\n  const queryVector = embedResponse.data![0].embedding!;\n\n  // vector search query code here\n}\n```\n\nThis 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`\n\nparameter. Finally, we specify the `inputType`\n\nas \"`query`\n\n\". Voyage AI distinguishes between the following two types of input:\n\n`\"document\"`\n\n— used when embedding data that gets saved into your database (the song vibe descriptions, in our case)\n`\"query\"`\n\n— used when embedding a search query at runtimeUsing the right input type improves retrieval quality. It's a small but important thing to keep in mind when implementing vector search applications.\n\nNow 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.\n\n```\n  // ... previous embedding code here\n\n  // vector search query code here \n  const collection = client.db(DB_NAME).collection(COLLECTION_NAME);\n\n  const results = await collection\n    .aggregate<SearchResult>([\n      {\n        $vectorSearch: {\n          index: VECTOR_INDEX,\n          path: \"embedding\",\n          queryVector,\n          numCandidates: NUM_CANDIDATES,\n          limit: NUM_RESULTS,\n        },\n      },\n      {\n        $project: {\n          _id: 0,\n          title: 1,\n          artist: 1,\n          genre_top: 1,\n          genres: 1,\n          score: { $meta: \"vectorSearchScore\" },\n        },\n      },\n    ])\n    .toArray();\n\n  return { results, queryVector };\n```\n\nTo implement vector search, we use the `$vectorSearch`\n\nstage of the [aggregation pipeline](https://www.mongodb.com/docs/manual/core/aggregation-pipeline/?utm_campaign=devrel&utm_source=third-party-content&utm_medium=other). The `$vectorSearch`\n\nstage takes the following parameters:\n\n`index`\n\n— The name of the vector search index we created during seeding\n`path`\n\n— The field in the document that holds the embedding (in this case, named `\"embedding`\n\n\").\n`queryVector`\n\n— The vector we just generated from the user's query.\n`numCandidates`\n\n— How many candidate documents to consider before ranking. A higher number means the search will be more thorough but slower.\n`limit`\n\n— How many results to return.After the search stage, a `$project`\n\nstage shapes our output, with `{ $meta: \"vectorSearchScore\" }`\n\npulling in the similarity score for each result — a number between 0 and 1, where higher means more similar.\n\nAnd that's all we need for our search functionality! Next, we'll build out a simple server with an endpoint to call this function.\n\nCreate a new file called `server.ts`\n\nin the `src`\n\ndirectory. This is where we will create any endpoints we might need. In this tutorial, we will create a single endpoint to search for songs.\n\nTo start, paste the following code into s`erver.ts`\n\n, which specifies the imports and sets up an Express server:\n\n``` python\nimport \"dotenv/config\";\nimport express from \"express\";\nimport path from \"path\";\nimport { client, searchSongs, VECTOR_INDEX, NUM_RESULTS, NUM_CANDIDATES } from \"./search\";\n\nconst app = express();\nconst PORT = process.env.PORT ?? 3000;\n\napp.use(express.json());\napp.use(express.static(path.join(__dirname, \"../public\")));\n```\n\nNow, we'll create our POST route that calls the `searchSongs`\n\nfunction we created earlier. Paste the following code into `server.ts`\n\n:\n\n``` js\napp.post(\"/api/search\", async (req, res) => {\n  const { query } = req.body;\n\n  if (!query || typeof query !== \"string\" || query.trim() === \"\") {\n    res.status(400).json({ error: \"A search query is required.\" });\n    return;\n  }\n\n  let results, queryVector;\n  try {\n    ({ results, queryVector } = await searchSongs(query.trim()));\n  } catch (err) {\n    console.error(\"Search failed:\", err);\n    res.status(500).json({ error: \"Search failed. Please try again.\" });\n    return;\n  }\n\n  res.json({\n    results\n  });\n});\n```\n\nIn the preceding code, it first checks that the query is valid. Then it sends the query to our `searchSongs()`\n\nfunction and returns the results. If there is an error, it returns an error message.\n\nThe 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`\n\n:\n\n```\nasync function startServer() {\n  await client.connect();\n  console.log(\"Connected to MongoDB Atlas.\");\n\n  app.listen(PORT, () => {\n    console.log(`Server running at http://localhost:${PORT}`);\n  });\n\n  process.on(\"SIGINT\", async () => {\n    await client.close();\n    process.exit(0);\n  });\n\n  process.on(\"SIGTERM\", async () => {\n    await client.close();\n    process.exit(0);\n  });\n}\n\nstartServer();\n```\n\nNow that we have our server set up, let's create a simple front end to interact with it.\n\nSince the purpose of this tutorial is the search functionality itself, we'll keep the front end simple. Create a new directory called `public`\n\nin the root of your project (it's important to keep the name as `public`\n\nsince our server is already set up to serve files from this directory). Then, create a new file called `index.html`\n\nin that folder.\n\nPaste the following code into `index.html`\n\n:\n\n```\n<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Vibe Search</title>\n    <link rel=\"stylesheet\" href=\"style.css\" />\n  </head>\n  <body>\n    <div class=\"container\">\n      <header>\n        <h1>Vibe Search</h1>\n        <p>Describe a mood or feeling and we'll find songs that match.</p>\n      </header>\n\n      <form id=\"search-form\">\n        <input\n          id=\"query-input\"\n          type=\"text\"\n          placeholder=\"e.g. melancholy rainy afternoon, upbeat road trip...\"\n          autocomplete=\"off\"\n        />\n        <button type=\"submit\">Search</button>\n      </form>\n      <p class=\"token-note\">Note: Each search generates a Voyage AI embedding and consumes tokens.</p>\n\n      <div id=\"results\"></div>\n    </div>\n\n    <script src=\"app.js\"></script>\n  </body>\n</html>\n```\n\nThis HTML creates a simple page with a header, an input box, and a section for results.\n\nNow we need to create the `style.css`\n\nand `app.js`\n\nfiles. Create a new file called `style.css`\n\nin the `public`\n\ndirectory and paste the following code into it:\n\n```\n*, *::before, *::after {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif;\n  background: #0f0f0f;\n  color: #e8e8e8;\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 2rem 1rem;\n}\n\n.container {\n  width: 100%;\n  max-width: 720px;\n}\n\nheader {\n  text-align: center;\n  margin-bottom: 2.5rem;\n}\n\nheader h1 {\n  font-size: 2.5rem;\n  font-weight: 700;\n  letter-spacing: -0.5px;\n  margin-bottom: 0.5rem;\n}\n\nheader p {\n  color: #777;\n  font-size: 1rem;\n}\n\n/* ── Search form ── */\n\n#search-form {\n  display: flex;\n  gap: 0.5rem;\n  margin-bottom: 2.5rem;\n}\n\n#query-input {\n  flex: 1;\n  padding: 0.75rem 1rem;\n  font-size: 1rem;\n  background: #1a1a1a;\n  border: 1px solid #333;\n  border-radius: 8px;\n  color: #e8e8e8;\n  outline: none;\n}\n\n#query-input:focus {\n  border-color: #555;\n}\n\n#query-input::placeholder {\n  color: #555;\n}\n\n#search-form button {\n  padding: 0.75rem 1.5rem;\n  font-size: 1rem;\n  font-weight: 600;\n  background: #00ed64;\n  color: #000;\n  border: none;\n  border-radius: 8px;\n  cursor: pointer;\n}\n\n#search-form button:hover {\n  background: #00c853;\n}\n\n/* ── Token note ── */\n\n.token-note {\n  font-size: 1rem;\n  color: #989898;\n  text-align: center;\n  margin-top: 0.6rem;\n  margin-bottom: 0;\n}\n\n/* ── Spinner ── */\n\n.spinner {\n  width: 28px;\n  height: 28px;\n  border: 3px solid #2a2a2a;\n  border-top-color: #00ed64;\n  border-radius: 50%;\n  margin: 2rem auto;\n  animation: spin 0.7s linear infinite;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* ── Result cards ── */\n\n#results {\n  display: flex;\n  flex-direction: column;\n  gap: 1rem;\n}\n\n.card {\n  background: #1a1a1a;\n  border: 1px solid #2a2a2a;\n  border-radius: 12px;\n  overflow: hidden;\n}\n\n.card-color-bar {\n  height: 6px;\n}\n\n.card-body {\n  padding: 1.25rem 1.5rem;\n}\n\n.card-title {\n  font-size: 1.1rem;\n  font-weight: 600;\n  margin-bottom: 0.2rem;\n}\n\n.card-artist {\n  color: #777;\n  font-size: 0.9rem;\n  margin-bottom: 0.85rem;\n}\n\n.tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 0.35rem;\n  margin-bottom: 1rem;\n}\n\n.tag {\n  font-size: 0.75rem;\n  padding: 0.2rem 0.65rem;\n  border-radius: 999px;\n  background: #252525;\n  color: #999;\n}\n\n.card-footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.score {\n  font-size: 0.8rem;\n  color: #555;\n}\n\n.fma-link {\n  font-size: 0.8rem;\n  color: #00ed64;\n  text-decoration: none;\n}\n\n.fma-link:hover {\n  text-decoration: underline;\n}\n\n.message {\n  text-align: center;\n  color: #555;\n  padding: 2rem 0;\n  font-size: 0.95rem;\n}\n\n.message.error {\n  color: #e05252;\n}\n```\n\nThe CSS added here is outside of the scope of this tutorial, but it just makes the app easier to look at and use.\n\nThe last file we need to create is `app.js`\n\n, which contains the front-end JavaScript code. Create a new file called `app.js`\n\nin the `public`\n\ndirectory and paste the following code into it:\n\n``` js\nconst DEFAULT_GRADIENT = \"linear-gradient(135deg, #434343, #1a1a1a)\";\n\nfunction renderResults(results) {\n  const container = document.getElementById(\"results\");\n\n  if (results.length === 0) {\n    container.innerHTML = '<p class=\"message\">No results found. Try a different vibe.</p>';\n    return;\n  }\n\n  container.innerHTML = results.map((result) => {\n    const score = Math.round(result.score * 100);\n    const tags = result.genres.map((g) => `<span class=\"tag\">${g}</span>`).join(\"\");\n\n    return `\n      <div class=\"card\">\n        <div class=\"card-color-bar\" style=\"background: ${DEFAULT_GRADIENT}\"></div>\n        <div class=\"card-body\">\n          <div class=\"card-title\">${result.title}</div>\n          <div class=\"card-artist\">${result.artist}</div>\n          <div class=\"tags\">${tags}</div>\n          <div class=\"card-footer\">\n            <span class=\"score\">Match: ${score}%</span>\n            <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>\n          </div>\n        </div>\n      </div>\n    `;\n  }).join(\"\");\n}\n\ndocument.getElementById(\"search-form\").addEventListener(\"submit\", async (e) => {\n  e.preventDefault();\n\n  const query = document.getElementById(\"query-input\").value.trim();\n  if (!query) return;\n\n  const container = document.getElementById(\"results\");\n  container.innerHTML = '<div class=\"spinner\"></div>';\n\n  const response = await fetch(\"/api/search\", {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ query }),\n  });\n\n  if (!response.ok) {\n    container.innerHTML = '<p class=\"message error\">Something went wrong. Please try again.</p>';\n    return;\n  }\n\n  const { results } = await response.json();\n  renderResults(results);\n});\n```\n\nThis JavaScript code adds interactivity to the page. It sends the search query to the server and displays the search results.\n\nAt this point, you should have everything set up. Make sure you have already run the `npm run seed\\`\n\ncommand from earlier in the tutorial.\n\nThen, run the following command to start the server:\n\n```\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) and try a few searches, such as:\n\n- \"melancholy rainy afternoon\"\n\n- \"high energy workout, aggressive beats\"\n\n- \"peaceful background music for focusing\"\n\nYou 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.\n\nAnd 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.\n\nIn 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.\n\nIf you look at the [GitHub repo](https://github.com/mongodb-developer/song-vibe-recommendations), you'll notice that there are a few files in the `seed`\n\ndirectory that we didn't use in this tutorial. The `fetchTracks.ts`\n\nfile 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.\n\nThe `generateVibeDescriptions.ts`\n\nand `generateEmbeddings.ts`\n\nfiles contain the code used to generate the \"vibe descriptions\" and embeddings for the sample data. The `generateVibeDescriptions.ts`\n\nfile uses the Gemini API to generate a text description of the \"vibe\" of each song. The `generateEmbeddings.ts`\n\nfile uses the Voyage AI API to generate an embedding for each description.\n\nBefore 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`\n\n.\n\nTo 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](https://aistudio.google.com/apikey) and creating a new project. Then, add your API key to the `.env`\n\nfile:\n\n```\nGEMINI_API_KEY=<Your Gemini API Key>\n```\n\nThen, create a file in the `seed`\n\ndirectory called `generateVibeDescriptions.ts`\n\nand paste the following code into it:\n\n``` python\nimport \"dotenv/config\";\nimport { GoogleGenAI } from \"@google/genai\";\nimport type { RawTrack } from \"./fetchTracks\";\n\nconst GEMINI_API_KEY = process.env.GEMINI_API_KEY;\nconst GEMINI_MODEL = \"gemini-3-flash-preview\";\nconst BATCH_SIZE = 50;\n\nexport interface TrackWithVibe extends RawTrack {\n  vibe_description: string;\n}\n\nfunction buildPrompt(tracks: RawTrack[]): string {\n  const trackList = tracks\n    .map(\n      (t, i) =>\n        `${i + 1}. Title: \"${t.title}\" | Artist: \"${t.artist}\" | Album: \"${t.album}\" | Genres: ${t.genres.join(\", \")}`\n    )\n    .join(\"\\n\");\n\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}`;\n}\n\nfunction parseResponse(text: string): string[] {\n  // Extract the JSON array by finding the outermost [ ... ] bounds.\n  // This handles cases where the model adds explanatory text before or after the JSON.\n  const start = text.indexOf(\"[\");\n  const end = text.lastIndexOf(\"]\");\n  if (start === -1 || end === -1 || end < start) {\n    throw new Error(`No JSON array found in model response:\\n${text}`);\n  }\n  return JSON.parse(text.slice(start, end + 1));\n}\n\nexport async function generateVibeDescriptions(\n  tracks: RawTrack[]\n): Promise<TrackWithVibe[]> {\n  if (!GEMINI_API_KEY) {\n    throw new Error(\n      \"GEMINI_API_KEY is not set. Add it to your .env file.\\n\" +\n      \"Get a free key at: https://aistudio.google.com/apikey\"\n    );\n  }\n\n  const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });\n  const results: TrackWithVibe[] = [];\n  const totalBatches = Math.ceil(tracks.length / BATCH_SIZE);\n\n  console.log(`\\nGenerating vibe descriptions via Gemini (${totalBatches} batches)...`);\n\n  for (let i = 0; i < tracks.length; i += BATCH_SIZE) {\n    const batch = tracks.slice(i, i + BATCH_SIZE);\n    const batchNum = Math.floor(i / BATCH_SIZE) + 1;\n\n    console.log(`  Batch ${batchNum}/${totalBatches}`);\n\n    const response = await ai.models.generateContent({\n      model: GEMINI_MODEL,\n      contents: buildPrompt(batch),\n    });\n\n    const descriptions = parseResponse(response.text ?? \"\");\n\n    if (descriptions.length !== batch.length) {\n      throw new Error(\n        `Batch ${batchNum}: expected ${batch.length} descriptions, got ${descriptions.length}`\n      );\n    }\n\n    for (let j = 0; j < batch.length; j++) {\n      results.push({ ...batch[j], vibe_description: descriptions[j] });\n    }\n\n  }\n\n  console.log(`  ${results.length} vibe descriptions generated.`);\n  return results;\n}\n```\n\nIn 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.\n\nThen, the `generateVibeDescriptions`\n\nfunction 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.\n\nNow 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.\n\nCreate a file in the `seed`\n\ndirectory called `generateEmbeddings.ts`\n\nand paste the following code into it:\n\n``` python\nimport \"dotenv/config\";\nimport { VoyageAIClient } from \"voyageai\";\nimport type { TrackWithVibe } from \"./generateVibeDescriptions\";\n\nconst EMBEDDING_MODEL = \"voyage-3-lite\";\n\nexport interface TrackWithEmbedding extends TrackWithVibe {\n  embedding: number[];\n}\n\nexport async function generateEmbeddings(\n  tracks: TrackWithVibe[]\n): Promise<TrackWithEmbedding[]> {\n  const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;\n  if (!VOYAGE_API_KEY) {\n    throw new Error(\"Missing VOYAGE_API_KEY in environment\");\n  }\n\n  const voyage = new VoyageAIClient({ apiKey: VOYAGE_API_KEY });\n\n  console.log(`\\nGenerating embeddings for ${tracks.length} tracks...`);\n\n  const response = await voyage.embed({\n    model: EMBEDDING_MODEL,\n    input: tracks.map((t) => t.vibe_description),\n    inputType: \"document\",\n  });\n\n  const results: TrackWithEmbedding[] = [];\n\n  for (let i = 0; i < tracks.length; i++) {\n    const embeddingData = response.data?.[i];\n    if (!embeddingData?.embedding) {\n      console.warn(`  Warning: no embedding returned for \"${tracks[i].title}\" — skipping`);\n      continue;\n    }\n    results.push({ ...tracks[i], embedding: embeddingData.embedding });\n  }\n\n  console.log(`  ${results.length} embeddings generated.`);\n  return results;\n}\n```\n\nThe main difference in embedding this data is that we specify the input type as \"`document`\n\n\" rather than \"`query`\n\n\". This distinction is important when embedding data with Voyage AI, as it embeds the data slightly differently depending on the type you specify.\n\nNow 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`\n\nfile in the `seed`\n\ndirectory and call the functions before inserting the data into the database. It will look something like this:\n\n``` js\nconst tracksWithVibes = await generateVibeDescriptions(rawTracks);\n\nconst tracksWithEmbeddings = await generateEmbeddings(tracksWithVibes);\n\nconst documents = tracksWithEmbeddings.map((track) => ({\n...track,\nseeded_at: new Date(),\n}));\n\nconsole.log(`\\nInserting ${documents.length} documents into MongoDB...`);\nawait collection.insertMany(documents);\n```\n\nNote 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).\n\nNow, 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.\n\nYou 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.\n\nHopefully, this gives you some insight into just how powerful MongoDB Vector Search can be. If you want to learn more, check out [our documentation](https://www.mongodb.com/docs/vector-search/?utm_campaign=devrel&utm_source=third-party-content&utm_medium=other) or leave a comment letting me know how you’d like to see this expanded on!", "url": "https://wpnews.pro/news/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai", "canonical_source": "https://dev.to/mongodb/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai-a0m", "published_at": "2026-07-14 14:08:37+00:00", "updated_at": "2026-07-14 14:30:22.618078+00:00", "lang": "en", "topics": ["artificial-intelligence", "machine-learning", "natural-language-processing", "developer-tools", "ai-tools"], "entities": ["MongoDB", "Voyage AI", "Free Music Archive", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai", "markdown": "https://wpnews.pro/news/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai.md", "text": "https://wpnews.pro/news/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai.txt", "jsonld": "https://wpnews.pro/news/building-a-vibe-based-music-recommender-with-mongodb-and-voyage-ai.jsonld"}}