Skip to main content
In this tutorial, you’ll learn how to integrate Voyage AI with Modelence to build powerful semantic search capabilities. You’ll learn how to:
  • Generate embeddings using Voyage AI’s embedding models
  • Store embeddings in MongoDB using vector fields
  • Perform vector search with MongoDB’s vector search capabilities
  • Use reranking to improve search results
This tutorial assumes you’ve already created a Modelence project and completed the setup. If you haven’t done so, please complete those steps first.

What is Voyage AI?

Voyage AI provides state-of-the-art embedding and reranking models that enable semantic search, recommendation systems, and RAG (Retrieval-Augmented Generation) applications. Their models are optimized for:
  • High-quality embeddings for semantic similarity
  • Reranking to improve search result relevance
  • Domain-specific fine-tuning options

Prerequisites

Before starting, you’ll need:
  1. A Voyage AI API key - Get one here
  2. A Modelence account with MongoDB configured

Quick Start with Template

The fastest way to get started is using the Voyage AI template:
npx create-modelence-app@latest my-voyage-app --template voyage-ai
This creates a ready-to-use project with all the code from this tutorial pre-configured.

Setup Steps

  1. Create an account on cloud.modelence.com
  2. Create an application and local environment in the Modelence Cloud dashboard
  3. Open the Settings page of your environment and click on Setup Local Environment
  4. Copy the command from the “Connect to Modelence Cloud” section and execute it in your project directory
  5. Add your Voyage AI API key:
    • Get your API key from voyageai.com
    • Add it as the value of MODELENCE_VOYAGEAI_API_KEY in your .modelence.env file
  6. Run the project:
    npm run dev
    
You can also view a live demo of the complete example.

Manual Setup

If you want to add Voyage AI to an existing project, follow these steps:

Step 1: Install Dependencies

Install the Voyage AI client library:
npm install voyageai

Step 2: Create a Document Store with Vector Embeddings

Stores in Modelence support vector embeddings out of the box with the schema.embedding() type and vector search indexes. Create a new directory src/server/voyage and add a db.ts file:
src/server/voyage/db.ts
import { Store, schema } from 'modelence/server';

export const dbDocuments = new Store('documents', {
  schema: {
    content: schema.string(),
    metadata: schema.object({
      title: schema.string(),
      description: schema.string(),
    }),
    embedding: schema.embedding(),
    createdAt: schema.date(),
  },
  indexes: [
    { key: { createdAt: -1 } },
  ],
  searchIndexes: [
    Store.vectorIndex({
      field: 'embedding',
      dimensions: 1024, // Voyage-3.5 default (supports 256, 512, 1024, 2048)
    }),
  ],
});
The schema.embedding() type is a special type for storing vector embeddings. The vectorIndex() method creates a MongoDB vector search index for fast similarity searches.

Step 3: Create Voyage AI Helper Functions

Create a voyage.ts file to handle embedding generation and reranking:
src/server/voyage/voyage.ts
import { getConfig } from 'modelence/server';
import { VoyageAIClient } from 'voyageai';

let voyageClient: VoyageAIClient | null = null;

export function getVoyageClient() {
  if (!voyageClient) {
    const apiKey = getConfig('voyage.apiKey') as string || process.env.VOYAGE_API_KEY;
    if (!apiKey) {
      throw new Error('VOYAGE_API_KEY environment variable is not set');
    }
    voyageClient = new VoyageAIClient({ apiKey });
  }
  return voyageClient;
}

export async function generateEmbedding(
  text: string,
  inputType: 'query' | 'document' = 'document'
): Promise<number[]> {
  const client = getVoyageClient();
  const result = await client.embed({
    input: [text],
    model: 'voyage-3.5',
    inputType,
  });

  return result.data?.[0].embedding || [];
}

export async function rerank<T extends Record<string, any>>(
  results: T[],
  field: string,
  query: string
) {
  const client = getVoyageClient();
  const rerankedResponse = await client.rerank({
    model: 'rerank-2.5',
    query: query,
    documents: results.map(doc => doc[field]),
    topK: 10,
  });

  // Map the reranked results back to the original documents
  return rerankedResponse.data?.map(rerankedDoc => {
    const index = rerankedDoc.index || 0;
    return {
      ...results[index],
      score: rerankedDoc.relevanceScore
    };
  }) || results;
}

Key Points:

  • Input Type: Voyage AI supports different input types (query vs document) to optimize embeddings for different use cases
  • Model Selection: voyage-3.5 is the latest embedding model supporting 4 dimension options (256, 512, 1024 default, and 2048). See all available embedding models
  • Reranking: Improves search results by reordering them based on relevance to the query. See all available reranker models

Step 4: Create a Module with Search Capabilities

Create an index.ts file to tie everything together:
src/server/voyage/index.ts
import { Module } from 'modelence/server';
import { z } from 'zod';
import { dbDocuments } from './db';
import { generateEmbedding, rerank } from './voyage';

export default new Module('voyage', {
  stores: [dbDocuments],
  queries: {
    async getDocuments() {
      return dbDocuments.fetch({}, {
        sort: { createdAt: -1 },
        limit: 50,
      });
    },
    async searchSimilar(args) {
      const { query } = z.object({
        query: z.string(),
      }).parse(args);

      // Generate embedding for the query
      const queryEmbedding = await generateEmbedding(query, 'query');

      // Perform vector search using MongoDB
      const results = await (await dbDocuments.vectorSearch({
        field: 'embedding',
        embedding: queryEmbedding,
        numCandidates: 100,
        limit: 10,
        projection: {
          content: 1,
          metadata: 1,
          createdAt: 1,
        },
      })).toArray();

      // Rerank results for better relevance
      return await rerank(results, 'content', query);
    },
  },
  mutations: {
    async addDocument(args) {
      const { title, description } = z.object({
        title: z.string().min(1),
        description: z.string().min(1),
      }).parse(args);

      // Combine title and description for embedding
      const content = `${title}\n${description}`;

      // Generate embedding for the document
      const embedding = await generateEmbedding(content, 'document');

      const result = await dbDocuments.insertOne({
        content,
        metadata: {
          title,
          description,
        },
        embedding,
        createdAt: new Date(),
      });

      return {
        id: result.insertedId.toString(),
        content,
        metadata: { title, description },
        createdAt: new Date(),
      };
    },
    async deleteDocument(args) {
      const { id } = z.object({
        id: z.string(),
      }).parse(args);

      await dbDocuments.deleteOne({ id });
      return { success: true };
    },
  },
  configSchema: {
    apiKey: {
      type: 'string',
      isPublic: false,
      default: '',
    },
  },
});
The vectorSearch() method performs semantic search using MongoDB’s vector search capabilities:
  • field: The field containing the embedding vectors
  • embedding: The query embedding to search for
  • numCandidates: Number of candidates to consider (higher = more accurate but slower)
  • limit: Maximum number of results to return
  • projection: Fields to include in results

Step 5: Include the Module

Add the Voyage module to your main server file:
src/server/app.ts
import { startApp } from 'modelence/server';
import voyageModule from './voyage';

startApp({
  modules: [voyageModule]
});

Step 6: Configure the API Key

You can configure the Voyage AI API key in two ways:

Option 1: Environment Variable

Add to your .env file:
VOYAGE_API_KEY=your_api_key_here

Option 2: Module Config

Store it securely in MongoDB using Modelence’s config system:
import { setConfig } from 'modelence/server';

await setConfig('voyage.apiKey', 'your_api_key_here');

Step 7: Build the Frontend

Create a React component to interact with your semantic search backend:
src/client/pages/VoyageSearchPage.tsx
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { modelenceMutation, modelenceQuery } from '@modelence/react-query';

interface Document {
  _id: string;
  content: string;
  metadata: {
    title: string;
    description: string;
  };
  createdAt: Date;
}

interface SearchResult extends Document {
  score: number;
}

export default function VoyageSearchPage() {
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);

  const { data: documents, refetch } = useQuery<Document[]>(
    modelenceQuery('voyage.getDocuments')
  );
  const addDocument = useMutation(modelenceMutation('voyage.addDocument'));
  const deleteDocument = useMutation(modelenceMutation('voyage.deleteDocument'));
  const searchSimilar = useMutation(modelenceMutation('voyage.searchSimilar'));

  const handleAddDocument = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim() || !description.trim()) return;

    await addDocument.mutateAsync({ title, description });
    setTitle('');
    setDescription('');
    refetch();
  };

  const handleSearch = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!searchQuery.trim()) return;

    const results = await searchSimilar.mutateAsync({ query: searchQuery });
    setSearchResults(results as SearchResult[]);
  };

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8">Semantic Search with Voyage AI</h1>

      {/* Search Form */}
      <div className="mb-8 p-6 bg-white rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-4">Search Documents</h2>
        <form onSubmit={handleSearch} className="flex gap-3">
          <input
            type="text"
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            placeholder="Enter your search query..."
            className="flex-1 px-4 py-2 border rounded"
          />
          <button
            type="submit"
            disabled={searchSimilar.isPending}
            className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
          >
            {searchSimilar.isPending ? 'Searching...' : 'Search'}
          </button>
        </form>

        {/* Search Results */}
        {searchResults.length > 0 && (
          <div className="mt-6 space-y-3">
            <h3 className="font-medium">
              Found {searchResults.length} results
            </h3>
            {searchResults.map((result) => (
              <div key={result._id} className="p-4 bg-blue-50 rounded border">
                <h4 className="font-semibold">{result.metadata.title}</h4>
                <p className="text-sm text-gray-700 mt-2">
                  {result.metadata.description}
                </p>
                <div className="text-xs text-gray-500 mt-2">
                  Relevance: {(result.score * 100).toFixed(0)}%
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* Add Document Form */}
      <div className="mb-8 p-6 bg-white rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-4">Add New Document</h2>
        <form onSubmit={handleAddDocument} className="space-y-4">
          <input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Document title..."
            className="w-full px-4 py-2 border rounded"
          />
          <textarea
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            placeholder="Document description..."
            rows={4}
            className="w-full px-4 py-2 border rounded"
          />
          <button
            type="submit"
            disabled={addDocument.isPending}
            className="px-6 py-2 bg-green-600 text-white rounded hover:bg-green-700"
          >
            {addDocument.isPending ? 'Adding...' : 'Add Document'}
          </button>
        </form>
      </div>

      {/* Documents List */}
      <div className="p-6 bg-white rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-4">All Documents</h2>
        {documents?.length === 0 ? (
          <p className="text-gray-500">No documents yet</p>
        ) : (
          <div className="space-y-3">
            {documents?.map((doc) => (
              <div key={doc._id} className="p-4 border rounded">
                <h4 className="font-semibold">{doc.metadata.title}</h4>
                <p className="text-sm text-gray-700 mt-2">
                  {doc.metadata.description}
                </p>
                <button
                  onClick={() => deleteDocument.mutateAsync({ id: doc._id })}
                  className="text-sm text-red-600 mt-2"
                >
                  Delete
                </button>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

How It Works

  1. Document Ingestion: When you add a document, the content is sent to Voyage AI to generate an embedding vector
  2. Storage: The embedding is stored alongside the document in MongoDB
  3. Search: When searching, your query is converted to an embedding and MongoDB finds similar vectors
  4. Reranking: Results are reranked using Voyage AI’s reranking model for improved relevance

Use Cases

This pattern is perfect for:
  • Knowledge bases with semantic search
  • Support chatbots with contextual document retrieval
  • Recommendation systems based on content similarity
  • RAG applications for AI assistants

Complete Example

Want to see the full working code? Check out the complete example:

Complete Voyage AI Example

See the complete source code with a polished UI and additional features.

Next Steps

I