DEV Community

Cover image for Using LlamaIndex and MongoDB to Build a Job Search Assistant
alisdairbr for Koyeb

Posted on • Originally published at koyeb.com

Using LlamaIndex and MongoDB to Build a Job Search Assistant

In today's job market, finding the perfect role often feels like searching for a needle in a haystack. One solution is to use AI-driven chatbots to streamline this process, offering quick responses and personalized assistance.

However, relying solely on full-text search can be limiting, as job descriptions vary widely and users may input non-standard terms. By using AI techniques like Retrieval-Augmented Generation (RAG) with LlamaIndex, you can build chatbots that not only understand the nuances of job description, but also make the job search experience more efficient and human-like.

In this tutorial, you will learn how to integrate LlamaIndex with MongoDB to create an AI-driven job search assistant that not only responds to user queries, but also continuously updates its knowledge base in real time, ensuring accurate and relevant results.

You can consult the project repository as work through this guide. After provisioning the MongoDB database, you can deploy the job assistant application as built in this tutorial using the Deploy to Koyeb button below:

Deploy to Koyeb

Once deployed, before using the application, you will need to seed at least one initial job (directed at your Koyeb app), and set up MongoDB Atlas vector search.

Note: You will need to replace the values of the environment variables in the configuration with your own OPENAI_API_KEY and MONGODB_URI. Consult the tutorial here to understand the expected values. You can leave the rest of the values as-is.

Requirements

To successfully follow this tutorial, you will need the following:

Demo

Before we jump into the technical stuff, let's look at a sneak peek of what you will be building in this tutorial:

Steps

  1. Generate an OpenAI token
  2. Provision a MongoDB database
  3. Create a new Next.js application
  4. Build the components of our application
  5. Define the Next.js application routes
  6. Deploy the Next.js application to Koyeb
  7. Conclusion

Generate an OpenAI token

HTTP requests to the OpenAI API require an authorization token. To generate this token, while logged into your OpenAI account, navigate to the API Keys page and click the Create new secret key button after entering name for your token. Copy and securely store this token for later use.

Now, let's move on to creating your MongoDB Database.

Provision a MongoDB database

We will be using MongoDB to store the data associated with our job search application persistently.

To get started, sign into MongoDB and select Database under the Deployment section. Click Build a database to get started with creating your own database.

Create new MongoDB database

Select M0 as the cluster type, and (optionally) give the cluster a personalized name. Click Create Deployment to proceed.

Configure MongoDB database

You may be directed to create a database user. Instead of limiting connections to your local IP address, you will want to ensure that all IPs are allowed (0.0.0.0/0) so that your application will work as expected when deployed to Koyeb.

Next, you'll be taken to the connection screen. Click Drivers in the Connect to your application section. On the page that follows, with the Node.js driver selected, click the copy icon associated with the MongoDB connection string:

Copy MongoDB connection string

This will copy the connection string with the password for the database user replaced by <password>. If necessary, you can manage and regenerate the password for your user on the Database Access page in the Security section of the left-hand menu.

Save the complete connection string in a safe place for later. Now, let's move on to creating a new Next.js application to interact with your MongoDB database.

Create a new Next.js application

Now that we have our database, let's get started by creating a new Next.js project.

In your terminal, type:

npx create-next-app@latest example-llamaindex-job-search
Enter fullscreen mode Exit fullscreen mode

When prompted, choose:

  • Yes when prompted to use TypeScript.
  • No when prompted to use ESLint.
  • Yes when prompted to use Tailwind CSS.
  • No when prompted to use src/ directory.
  • Yes when prompted to use App Router.
  • No when prompted to customize the default import alias (@/*).

Once that is done, move into the project directory and start the app in development mode by executing the following command:

cd example-llamaindex-job-search
npm run dev
Enter fullscreen mode Exit fullscreen mode

The app should be running on localhost:3000. Stop the development server by pressing CTRL-C when you are finished.

Next, create an .env.local file to set up the environment variables the application needs. Replace the values with the OpenAI API key and MongoDB connection string you copied earlier. We'll also take the opportunity to set a few other MongoDB variables that our application will look for later:

OPENAI_API_KEY="<YOUR_OPENAI_API_KEY>"
MONGODB_URL="<YOUR_MONGODB_CONNECTION_STRING>"
MONGODB_DATABASE="jobs_db"
MONGODB_VECTORS="jobs_vectors"
MONGODB_VECTOR_INDEX="jobs_vectors_index"
Enter fullscreen mode Exit fullscreen mode

Next.js will automatically load these variables when we start up the application locally.

Next, install the necessary dependencies with the following commands:

npm install llamaindex@0.3.4 mongodb@6.5.0 ai openai
npm install -D @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode

The command above installs the packages passed to the install command, with the -D flag specifying the libraries intended for development purposes only.

The libraries installed include:

  • ai: A library to build AI-powered streaming text and chat UIs.
  • mongodb: A driver for Node.js to easily interact with MongoDB databases.
  • llamaindex: A data framework for creating LLM applications.
  • openai: A library to conveniently access OpenAI's REST API.

The development-specific libraries include:

  • @tailwindcss/typography: A set of prose classes to style HTML elements (built from markdown).

Now, let's move on to creating components that will help you quickly prototype the UI and handle the complexities of creating a chatbot application with Next.js.

Build the components of our application

Configure shadcn/ui components

With shadcn/ui you will be able to use baked-in accessible <input> and <button> elements. Execute the command below to start setting up the shadcn/ui:

npx shadcn-ui@latest init
Enter fullscreen mode Exit fullscreen mode

You will be asked a few questions to configure a components.json, answer with the following:

✔ Would you like to use TypeScript (recommended)? no / **yes**
✔ Which style would you like to use? › **Default**
✔ Which color would you like to use as base color? › **Slate**
✔ Would you like to use CSS variables for colors? no / **yes**
Enter fullscreen mode Exit fullscreen mode

With above, you have set up a CLI that allows us to easily add React components to your Next.js application.

Before moving on, insert the following code in tailwind.config.ts to load the prose classes we installed earlier. Initializing shadcn/ui rewrites the tailwind.config.ts file, so we needed to defer this step until the file was in its current state:

import type { Config } from "tailwindcss";

const config = {
  // Rest of the file as-is
  plugins: [
    require("tailwindcss-animate")  // [!code --]
    require("tailwindcss-animate"),  // [!code ++]
    require("@tailwindcss/typography") // [!code ++]
  ],
} satisfies Config

export default config
Enter fullscreen mode Exit fullscreen mode

Execute the command below to get the button and input elements:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
Enter fullscreen mode Exit fullscreen mode

With the above, a ui directory should now exist inside the components directory containing button.tsx and input.tsx files.

Create a markdown component

For our application, we want to render the streaming markdown compatible responses from AI in a visually appealing manner. For this, you will use react-markdown library. Install it with the following command:

npm install react-markdown
Enter fullscreen mode Exit fullscreen mode

Afterwards, create a markdown.tsx file inside the components directory with the following code:

// File: components/markdown.tsx

import clsx from "clsx";
import ReactMarkdown from "react-markdown";

interface MarkdownProps {
  index: number;
  message: string;
}

const Markdown = ({ message, index }: MarkdownProps) => {
  return (
    <ReactMarkdown
      components={{
        a({ children, href }) {
          return (
            <a href={href} target="_blank" className="underline text-black">
              {children}
            </a>
          );
        },
      }}
      className={clsx(
        "w-full mt-4 pt-4 prose break-words prose-p:leading-relaxed prose-pre:p-0",
        index !== 0 && "border-t"
      )}
    >
      {message}
    </ReactMarkdown>
  );
};

export default Markdown;
Enter fullscreen mode Exit fullscreen mode

The code above imports the react-markdown library and uses it to style each HTML element.

Now, let's move to create the routes for the user-facing pages and API endpoints.

Define the Next.js application routes

To create interactive, user-facing pages in Next.js, you will use Next.js Client Components to create an interactive UI that is pre-rendered on the server and run with JavaScript the browser.

To create API endpoints in Next.js, you will use Next.js Route Handlers to serve responses via the web response API.

The structure below is what our app directory will look like at the end of this section:

├── page.tsx
├── api/
├────── chat/
├───────── route.ts
├────── train/
└───────── route.ts
Enter fullscreen mode Exit fullscreen mode
  • page.tsx will serve as the index route, i.e. localhost:3000.
  • api/chat/route.ts will serve responses for localhost:3000/api/chat.
  • api/train/route.ts will serve responses for localhost:3000/api/train.
URL Matched Routes
/ app/page.tsx
/api/chat app/api/chat/route.ts
/api/train app/api/train/route.ts

Create a job indexing API endpoint

First, create a directory named mongo inside the lib directory with the following command:

mkdir lib/mongo
Enter fullscreen mode Exit fullscreen mode

Then, create a file named instance.server.ts with the following code to setup a MongoDB client for your database:

// File: lib/mongo/instance.server.ts
import { MongoClient } from 'mongodb'

export const databaseName = process.env.MONGODB_DATABASE as string
export const indexName = process.env.MONGODB_VECTOR_INDEX as string
export const vectorCollectionName = process.env.MONGODB_VECTORS as string

export const client = new MongoClient(process.env.MONGODB_URI as string)
Enter fullscreen mode Exit fullscreen mode

In the code above, you have set up a MongoDB client instance using the MongoClient class and MONGODB_URI environment variable. Additionally, you have exported 3 variables representing the database, collection, and vector index names of your MongoDB instance as we configured earlier in the .env.local file.

Now, create a file named store.server.ts in the same directory with the following code to setup MongoDB Atlas Vector Search client for your MongoDB instance:

// File: lib/mongo/store.server.ts
import { MongoDBAtlasVectorSearch } from 'llamaindex/storage/vectorStore/MongoDBAtlasVectorStore'

import {
  vectorCollectionName as collectionName,
  databaseName as dbName,
  indexName,
  client as mongodbClient,
} from '@/lib/mongo/instance.server'

export default new MongoDBAtlasVectorSearch({
  mongodbClient,
  dbName,
  indexName,
  collectionName,
})
Enter fullscreen mode Exit fullscreen mode

In the code above, we export a MongoDBAtlasVectorSearch instance, configured with the MongoDB client, database name, index name, and collection name. It integrates with LlamaIndex's MongoDBAtlasVectorStore to facilitate vector storage and search functionalities within your Next.js application.

Using both of the above files, you can now create an endpoint that allows you to insert jobs data and index them for vector search simultaneously.

Create the app/api/train directory structure by typing:

mkdir -p app/api/train
Enter fullscreen mode Exit fullscreen mode

Inside the new directory, create a new file called route.ts with the following content:

import { Document, VectorStoreIndex, storageContextFromDefaults } from 'llamaindex'
import { NextRequest, NextResponse } from 'next/server'

import { client, databaseName, vectorCollectionName } from '@/lib/mongo/instance.server'
import vectorStore from '@/lib/mongo/store.server'

// File: app/api/train/route.ts

export const runtime = 'nodejs'

export async function POST(request: NextRequest) {
  try {
    const { jobs } = await request.json()
    // Get MongoDB database and collection
    const db = client.db(databaseName)
    const collection = db.collection(vectorCollectionName)
    // Insert all jobs from the request in MongoDB
    await collection.insertMany(jobs)
    // Create LlamaIndex Text Documents containing job data as Metadata
    const documents = jobs.map(
      (job: Record<any, any>) =>
        new Document({
          // Use the job description
          text: job.job_description,
          // Attach metadata as the job JD
          metadata: JSON.parse(JSON.stringify(job)),
        })
    )
    // Index all the Text Documents in MongoDB Atlas
    const storageContext = await storageContextFromDefaults({ vectorStore })
    await VectorStoreIndex.fromDocuments(documents, { storageContext })
    return NextResponse.json({ code: 1 })
  } catch (e) {
    console.log(e)
    return NextResponse.json({ code: 0 })
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, you create a POST API endpoint to accept jobs as JSON data. The data is then inserted in your MongoDB database. Afterwards, all the documents stored in the documents array are inserted into the MongoDB vector search index. Under the hood, the vector embeddings for each document is generated using its text property.

Seed the job database

To be able to perform vector search using MongoDB Atlas Search, you need to have at least a single record in a MongoDB database. Hence, an endpoint was created first that you can use to populate your MongoDB database. Use the following command to populate your database via the API endpoint:

curl --location 'http://localhost:3000/api/train' \
--header 'Content-Type: application/json' \
--data '{
    "jobs": [
        {
            "job_title": "Customer Success Engineer",
            "job_description": "Reporting to the Senior Manager, Customer Success Engineering, the Customer Success Engineer'\''s responsibilities include resolving customer concerns, creating and improving internal tooling, and engineering solutions to help Vercel customers get the most out of the platform.",
            "job_link": "https://vercel.com/careers/customer-success-engineer-uk-5085742004"
        }
    ]
}'
Enter fullscreen mode Exit fullscreen mode

Now, let's move on to creating your own MongoDB Atlas vector search instance.

Set up MongoDB Atlas vector search

Navigate to your database cluster in the MongoDB dashboard. Click on the Atlas Search tab. Next, click Create Search Index to start creating the vector index.

Create search index

Select the JSON Editor method under the Atlas Vector Search section:

Select JSON for Atlas vector search

On the following page, select jobs_vectors collection in the jobs_db database to index job data.

Rename the Index Name to jobs_vectors_index (remember to update the value in the .env.local file if you choose to use a different index name) and edit the JSON to be the following:

{
  "fields": [
    {
      "type": "vector",
      "path": "embedding",
      "numDimensions": 1536,
      "similarity": "cosine"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Configure JSON for vector search

On the next page, click on Create Search Index button to finalize indexing:

Create search index button

After a few seconds, you should see the status turn into Active after which you can start querying for relevant vectors along with their job metadata:

Search index active

Now, let's move on to creating the chat user interface for the users to interact with.

Building the index route as the chatbot interface

Open the app/page.tsx file and replace the existing code with the following:

// File: app/page.tsx

"use client";

import { useChat } from "ai/react";
import Markdown from "@/components/markdown";
import { Input } from "@/components/ui/input";

export default function Home() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();
  return (
    <div className="mt-8 gap-y-4 px-4 md:px-0 w-full flex flex-col items-center">
      <h1 className="w-[300px] md:w-[600px] text-xl font-semibold">
        Job Assistant
      </h1>
      <form
        onSubmit={handleSubmit}
        className="border-t pt-8 mt-4 w-[300px] md:w-[600px] flex flex-col"
      >
        <Input
          id="message"
          value={input}
          type="message"
          autoComplete="off"
          onChange={handleInputChange}
          placeholder="What kind of role are you looking for?"
          className="border-black/25 hover:border-black placeholder:text-black/75 rounded"
        />
        <button
          type="submit"
          className="rounded max-w-max mt-2 px-3 py-1 border text-black hover:border-black"
        >
          Search &rarr;
        </button>
      </form>
      <div className="w-[300px] md:w-[600px] flex flex-col">
        {messages.map((i, _) => (
          <Markdown message={i.content} index={_} />
        ))}
      </div>
      <div className="mt-8 w-full">&nbsp;</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The code above begins by importing the useChat hook from ai package, the markdown component that you have created earlier to render each message with it, and the input element from the shadcn/ui. In the React component on the homepage, you will deconstruct the following with the useChat hook:

  • The reactive messages array which contains the conversation between the user and AI.
  • The reactive input value inserted by user into the input field.
  • The handleInputChange method to make sure the input value is in sync with the changes.
  • The handleSubmit method to call the API (/api/chat) to get a response for the user’s latest message.

Next, we'll move on to create the chat endpoint that contains references to jobs (including descriptions) related to the user query.

Create a chat API endpoint

Find relevant jobs using MongoDB Atlas vector search

A full text search, or searching through the entire job data stored in your database, is an expensive operation, both in terms of time taken as well as the relevance to the user query. To find relevant jobs easier and faster, you can use vector search for the same. It involves querying the existing set of vectors in your MongoDB Atlas instance. Furthermore, you can filter the vectors whose embeddings carry at least 90% similarity score to the vector embeddings of the user search.

Begin by creating the app/api/chat directory:

mkdir app/api/chat
Enter fullscreen mode Exit fullscreen mode

Inside, create a file called route.ts to handle the POST request created by useChat hook in our React component, with the aim to obtain a response from AI.

// File: app/api/chat/route.ts

export const runtime = "nodejs";

import { VectorStoreIndex } from "llamaindex";
import vectorStore from "@/lib/mongo/store.server";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  try {
    const { messages } = await request.json();
    const userMessages = messages.filter((i: Message) => i.role === "user");
    const query = userMessages[userMessages.length - 1].content;
    const index = await VectorStoreIndex.fromVectorStore(vectorStore);
    const retriever = index.asRetriever({ similarityTopK: 10 });
    const queryEngine = index.asQueryEngine({ retriever });
    const { sourceNodes } = await queryEngine.query({ query });
    let systemKnowledge: any[] = [];
    if (sourceNodes) {
      systemKnowledge = sourceNodes
        .filter((match) => match.score && match.score > 0.9)
        .map((match) => JSON.stringify(match.node.metadata));
    }
Enter fullscreen mode Exit fullscreen mode

In the code above, you create a POST API endpoint to accept the conversation between user and AI (i.e. messages) as JSON data. It then filters out user messages and extracts the last user message as a query. Afterwards, it creates a query engine from the index to fetch up to 10 highly relevant documents.

Next, it filters the results based on their similarity score, keeping those with a score greater than 0.9, indicating a high relevance. Finally, it creates an array with metadata of each highly relevant job to create a knowledge base for the chatbot.

Create system context and instructions for OpenAI

Next, we need to instantiate an OpenAI instance to create chat completion responses. The SDK will call the OpenAI chat completion API under the hood.

Create a file named openai.server.ts in the lib directory with the following code:

// File: lib/openai.server.ts
import OpenAI from 'openai'

// Instantiate class to generate text completion using the OpenAI API
export default new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
})
Enter fullscreen mode Exit fullscreen mode

At this point, you have created a knowledge base for chat with jobs data relevant to the user's query. It's now time to prompt the OpenAI chat completion model to enhance the AI response by inserting it as part of the system knowledge. You will want to get the response to the user from AI as soon as possible, so you will enable streaming using OpenAIStream exported by ai package.

To do implement of this, insert the following code in the app/api/chat/route.ts file:

import { type Message, OpenAIStream, StreamingTextResponse } from 'ai'
import { VectorStoreIndex } from 'llamaindex'
import { NextRequest, NextResponse } from 'next/server'

// [!code ++]
import vectorStore from '@/lib/mongo/store.server'
import completionServer from '@/lib/openai.server'

// File: app/api/chat/route.ts

export const runtime = 'nodejs'

// [!code ++]

export async function POST(request: NextRequest) {
  try {
    const { messages } = await request.json()
    const userMessages = messages.filter((i: Message) => i.role === 'user')
    const query = userMessages[userMessages.length - 1].content
    const index = await VectorStoreIndex.fromVectorStore(vectorStore)
    const retriever = index.asRetriever({ similarityTopK: 10 })
    const queryEngine = index.asQueryEngine({ retriever })
    const { sourceNodes } = await queryEngine.query({ query })
    let systemKnowledge: any[] = []
    if (sourceNodes) {
      systemKnowledge = sourceNodes
        .filter((match) => match.score && match.score > 0.9)
        .map((match) => JSON.stringify(match.node.metadata))
    }
    const completionResponse = await completionServer.chat.completions.create({
      // [!code ++]
      stream: true, // [!code ++]
      model: 'gpt-3.5-turbo', // [!code ++]
      messages: [
        // [!code ++]
        {
          // [!code ++]
          // create a system content message to be added as
          // the open ai text completion will supply it as the context with the API
          role: 'system', // [!code ++]
          content: `Behave like a Google for job listings. You have the knowledge of only the following job that are relevant to the user right now: [${JSON.stringify(
            // [!code ++]
            systemKnowledge // [!code ++]
          )}]. Each response should be in 100% markdown compatible format, an un-ordered list, have hyperlinks in it to the job taken from the job_link attribute and brief description of what's required in the job taken from the job_description attribute. Be precise.`, // [!code ++]
        }, // [!code ++]
        // also, pass the whole conversation!
        ...messages, // [!code ++]
      ], // [!code ++]
    }) // [!code ++]
    // Convert the response into a friendly text-stream
    const stream = OpenAIStream(completionResponse) // [!code ++]
    // Respond with the stream
    return new StreamingTextResponse(stream) // [!code ++]
  } catch (e) {
    // [!code ++]
    console.log(e) // [!code ++]
    return NextResponse.json(
      // [!code ++]
      'There was an Internal Server Error. Can you try again?', // [!code ++]
      {
        // [!code ++]
        status: 500, // [!code ++]
      } // [!code ++]
    ) // [!code ++]
  } // [!code ++]
} // [!code ++]
Enter fullscreen mode Exit fullscreen mode

The additions above prompt the Chat completion model using the chat.completions.create function. Each item being passed to the prompt function contains a "role" key which, in our case, can be:

  • system representing the system knowledge.
  • user representing the user message.
  • assistant representing the responses from the model.

To make sure that the chatbot responds in only the expected manner, the following is used in the content field to instruct that:

  • The chatbot should behave like Google for job listings.
  • The chatbot should make sure that the response is in markdown format.
  • The chatbot's response should have hyperlinks to the jobs.
  • The chatbot goes beyond just including references to the jobs.
  • The chatbot should be precise.

You have successfully created a chat endpoint that uses Retrieval Augmented Generation to provide job results closely tied to the user to the chatbot.

Now, let's deploy the application online on the Koyeb platform.

Deploy the Next.js application to Koyeb

Koyeb is a developer-friendly serverless platform to deploy apps globally. No-ops, servers, or infrastructure management is required. It supports many different tech stacks including Rust, Golang, Python, PHP, Node.js, Ruby, and Docker.

With the app now complete, the final step is to deploy it online on Koyeb.

You will use git-driven deployment to deploy on Koyeb. To do this, you need to create a new GitHub repository using the GitHub web interface or the GitHub CLI with the following command:

gh repo create <YOUR_GITHUB_REPOSITORY> --private
Enter fullscreen mode Exit fullscreen mode

Next.js automatically created a git repository with a relevant .gitignore file when we initialized our new project. Add the new GitHub repository as a remote by typing:

git remote add origin git@github.com:<YOUR_GITHUB_USERNAME>/<YOUR_GITHUB_REPOSITORY>.git
git branch -M main
Enter fullscreen mode Exit fullscreen mode

Next, add all the files in your project directory to the git repository and push them to GitHub by typing:

git add .
git commit -m "Initial commit"
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

To deploy the code on the GitHub repository, visit the Koyeb control panel, and while on the Overview tab, click the Create Web Service button to start the deployment process. On the App deployment page:

  1. Select the GitHub deployment option.
  2. Choose the GitHub repository for your code from the repository drop down menu. Alternatively, you can enter the example repo for this tutorial in the Public GitHub repository field: https://github.com/koyeb/example-llamaindex-mongodb-job-search.
  3. In the Environment variables section, click Bulk edit. Paste the variables defined in your .env.local file and click Save.
  4. Enter a name for the application or use the provided one.
  5. Finally, initiate the deployment process by selecting the Deploy button.

During the deployment on Koyeb, the process identifies the build and start scripts outlined in the package.json file, using them to build and launch the application. You can follow the deployment progress through the displayed logs. Upon the completion of deployment and the successful execution of vital health checks, your application will be operational.

Conclusion

In this tutorial, you created a job assistant application with Next.js using MongoDB Atlas vector search and the OpenAI chat completion API. During the process, you learned how to perform vector search with LlamaIndex on a MongoDB database to find relevant jobs for the user. You also used the OpenAI API to create markdown compatible responses containing a brief description with a link to job role(s).

Given that the application was deployed with the git deployment option, subsequent code pushes to the deployed branch will automatically initiate a new build for your application. Changes to your application will become live once the deployment is successful. In the event of a failed deployment, Koyeb retains the last operational production deployment, ensuring the uninterrupted operation of your application.

Top comments (0)