DEV Community

Cover image for Building a Chatbot using your documents with LangChain from Scratch
xbb
xbb

Posted on

Building a Chatbot using your documents with LangChain from Scratch

Introduction

This tutorial will guide you through creating a chatbot that uses your documents to provide intelligent responses. We'll leverage LangChain for natural language processing, document handling, and a vector database for efficient data retrieval. The stack includes Vite for project setup, React for the front-end, TailwindCSS for styling, OpenAI for generating responses, and Supabase as our vector database. This guide aims to provide a step-by-step approach to building a chatbot from scratch using LangChain, and it will also cover how to develop a chatbot using LangChain effectively. By the end of this tutorial, you'll know how to build an LLM RAG (Retrieval-Augmented Generation) Chatbot with LangChain.

You can find the complete project code on GitHub.

Who is this for?

This tutorial is designed for developers who:

  • Have basic knowledge of React and JavaScript.
  • Want to build AI-powered applications using their own documents.
  • Are looking to integrate vector databases and AI models into their projects.

What will be covered?

  • Setting up a new project with Vite and TailwindCSS.
  • Integrating LangChain with OpenAI for AI capabilities.
  • Using Supabase as a vector database to store and retrieve document data.
  • Building the front-end components for the chatbot.
  • Creating utilities for document handling and AI response generation.

Architecture

LLM RAG Architecture

Preparation

Create a vector store on Supabase, please refer to this GitHub Repo.

Step-by-Step Guide

Step 1: Initialize the Project with Vite

Create a new Vite project:

npm create vite@latest ai-assistant -- --template react
cd ai-assistant
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Necessary Dependencies

Install TailwindCSS and other dependencies:

npm install tailwindcss postcss autoprefixer react-router-dom @langchain/core @langchain/openai @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure TailwindCSS

Initialize TailwindCSS:

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Configure tailwind.config.js:

module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Enter fullscreen mode Exit fullscreen mode

Add Tailwind directives to src/index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Step 4: Configure Environment Variables

Create a .env file in the root of your project and add your API keys:

VITE_SUPABASE_BASE_URL=your-supabase-url
VITE_SUPABASE_API_KEY=your-supabase-api-key
VITE_OPENAI_API_KEY=your-openai-api-key
VITE_OPENAI_BASE_URL=https://api.openai.com/v1

Enter fullscreen mode Exit fullscreen mode

Step 5: Project Directory Structure

Organize your project files as follows:

src/
├── components/
│   ├── Avatar.jsx
│   ├── Chat.jsx
│   ├── ChatBox.jsx
│   ├── Header.jsx
│   └── Message.jsx
├── utils/
│   ├── chain.js
│   ├── combineDocuments.js
│   ├── formatConvHistory.js
│   └── retriever.js
├── App.jsx
├── main.jsx
├── index.css
└── custom.css

Enter fullscreen mode Exit fullscreen mode

Step 6: Main Entry Point

src/main.jsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import './custom.css'; // Custom CSS

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

Enter fullscreen mode Exit fullscreen mode

The main.jsx file sets up the React application by rendering the App component into the root element. It also imports the main styles, including TailwindCSS and any custom CSS.

Step 7: Creating Components

App Component

src/App.jsx

import Chat from './components/Chat'

function App() {
  return (
    <Chat />
  )
}

export default App

Enter fullscreen mode Exit fullscreen mode

The App component serves as the root component of the application. It simply renders the Chat component, which contains the main functionality of the AI assistant.

Chat Component

src/components/Chat.jsx

import { useState, useEffect } from 'react';
import Header from './Header';
import Message from './Message';
import ChatBox from './ChatBox';
import robotAvatar from '../assets/robot-avatar.png';
import userAvatar from '../assets/profile.jpg';
import { chain } from '../utils/chain';
import { formatConvHistory } from '../utils/formatConvHistory';

function Chat() {
  const [messages, setMessages] = useState(() => {
    // Retrieve messages from local storage if available
    const savedMessages = localStorage.getItem('messages');
    return savedMessages ? JSON.parse(savedMessages) : [];
  });

  const [isLoading, setIsLoading] = useState(false);

  // Save messages to local storage whenever they change
  useEffect(() => {
    localStorage.setItem('messages', JSON.stringify(messages));
  }, [messages]);

  // Handle new messages sent by the user
  const handleNewMessage = async (text) => {
    const newMessage = {
      time: new Date().toLocaleTimeString(),
      text,
      avatarSrc: userAvatar,
      avatarAlt: "User's avatar",
      position: "left",
      isRobot: false,
    };

    setMessages((prevMessages) => [...prevMessages, newMessage]);

    const updatedMessages = [...messages, newMessage];

    setIsLoading(true);

    try {
      // Invoke the LangChain API to get the AI's response
      const response = await chain.invoke({
        question: text,
        conv_history: formatConvHistory(updatedMessages.map(msg => msg.text)),
      });

      const aiMessage = {
        time: new Date().toLocaleTimeString(),
        text: response,
        avatarSrc: robotAvatar,
        avatarAlt: "Robot's avatar",
        position: "right",
        isRobot: true,
      };

      setMessages((prevMessages) => [...prevMessages, aiMessage]);
    } catch (error) {
      console.error("Error fetching AI response:", error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <main className="font-merriweather px-10 py-8 mx-auto w-full bg-sky-950 max-w-[480px] h-screen">
      <Header
        mainIconSrc={robotAvatar}
        mainIconAlt="Main icon"
        title="AI-Assistant"
      />
      <div id="chatbot-conversation-container" className="flex flex-col gap-y-2 mt-4">
        {messages.map((message, index) => (
          <Message
            key={index}
            time={message.time}
            text={message.text}
            avatarSrc={message.avatarSrc}
            avatarAlt={message.avatarAlt}
            position={message.position}
            isRobot={message.isRobot}
          />
        ))}
      </div>
      <div className="mt-auto mb-4">
        <ChatBox
          label="What's happening?"
          buttonText="Ask"
          onSubmit={handleNewMessage}
          isLoading={isLoading}
        />
      </div>
    </main>
  );
}

export default Chat;

Enter fullscreen mode Exit fullscreen mode

The Chat component is the core of the AI assistant, handling message state and interaction logic.

  • useState is used to initialize and manage the messages state, which holds the chat messages, and the isLoading state, which indicates if a message is being processed.
  • The useEffect hook ensures that messages are saved to local storage whenever the messages state changes. This allows chat history to persist across page reloads.
  • The handleNewMessage function processes new messages sent by the user. It creates a new message object, updates the state with the new message, and sends the message to the LangChain API to get a response.
  • The AI response is then added to the messages state, updating the chat interface with both the user and AI messages.
  • The return statement defines the layout of the chat interface, including the header, message list, and input box. The Header component displays the chat title and main icon, while the Message component renders each message in the chat history. The ChatBox component provides an input field and button for sending new messages.

Header Component

src/components/Header.jsx

import React from "react";
import PropTypes from "prop-types";
import Avatar from "./Avatar";

function Header({ mainIconSrc, mainIconAlt, title }) {
  return (
    <header className="flex flex-col mx-auto items-center grow shrink-0 basis-0 w-fit">
      <Avatar src={mainIconSrc} alt={mainIconAlt} className="aspect-square w-[129px]" />
      <h1 className="mt-3 text-4xl text-center text-stone-200">{title}</h1>
    </header>
  );
}

Header.propTypes = {
  mainIconSrc: PropTypes.string.isRequired,
  mainIconAlt: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

The Header component displays the title and main icon of the chat application.

Message Component

src/components/Message.jsx

import PropTypes from "prop-types";
import Avatar from "./Avatar";

const avatarStyles = "rounded-full shrink-0 self-end aspect-square w-[47px]";
const messageTimeStyles = "self-center mt-5 text-xs font-bold leading-4 text-slate-600";

function Message({ text, time, avatarSrc, avatarAlt, position, isRobot }) {

  const messageTextStyles = `text-sm leading-4 rounded-xl p-3 mt-2 ${isRobot ? "bg-sky-50 black-800" : "bg-cyan-800 text-white"
    } font-merriweather`; // Apply Merriweather font

  return (
    <section className={`flex gap-2 ${position === 'left' ? 'justify-start' : 'justify-end'} items-center`}>
      {position === 'left' && <Avatar src={avatarSrc} alt={avatarAlt} className={avatarStyles} />}
      <div className="flex flex-col grow shrink-0 basis-0 w-fit">
        {time && <time className={messageTimeStyles}>{time}</time>}
        <p className={messageTextStyles}>{text}</p>
      </div>
      {position === 'right' && <Avatar src={avatarSrc} alt={avatarAlt} className={avatarStyles} />}
    </section>
  );
}

Message.propTypes = {
  text: PropTypes.string.isRequired,
  time: PropTypes.string,
  avatarSrc: PropTypes.string.isRequired,
  avatarAlt: PropTypes.string.isRequired,
  position: PropTypes.oneOf(['left', 'right']).isRequired,
  isRobot: PropTypes.bool.isRequired,
};

export default Message;

Enter fullscreen mode Exit fullscreen mode
  • The Message component displays individual messages within the chat interface.
  • It uses the Avatar component to display the avatar of the message sender.
  • The messageTextStyles are applied to style the message text, with different styles for user and robot messages.

ChatBox Component

src/components/ChatBox.jsx

import { useState } from "react";
import PropTypes from "prop-types";

const inputStyles = "w-full px-4 pt-4 pb-4 mt-2 text-base leading-5 bg-sky-900 rounded-xl border-4 border-solid shadow-sm border-slate-600 text-gray-100 resize-none";
const buttonStyles = "w-full px-6 py-3 mt-3 text-2xl font-bold text-center text-white whitespace-nowrap rounded-xl bg-slate-600 hover:bg-slate-700 hover:translate-y-0.5 focus:outline-none ";
const buttonDisabledStyles = "w-full px-6 py-3 mt-3 text-2xl font-bold text-center text-white whitespace-nowrap rounded-xl bg-slate-600 opacity-50 cursor-not-allowed";

function ChatBox({ label, buttonText, onSubmit, isLoading }) {
  const [message, setMessage] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();
    if (message.trim() && !isLoading) {
      onSubmit(message);
      setMessage('');
    }
  };

  return (
    <section className="flex flex-col mt-4">
      <form onSubmit={handleSubmit}>
        <label htmlFor="chatInput" className="sr-only">{label}</label>
        <input
          id="chatInput"
          placeholder={label}
          className={inputStyles}
          value={message}
          onChange={(e) => setMessage(e.target.value)}
        />
        <button
          type="submit"
          className={isLoading ? buttonDisabledStyles : buttonStyles}
          disabled={isLoading}
        >
          {buttonText}
        </button>
      </form>
    </section>
  );
}

ChatBox.propTypes = {
  label: PropTypes.string.isRequired,
  buttonText: PropTypes.string.isRequired,
  onSubmit: PropTypes.func.isRequired,
  isLoading: PropTypes.bool.isRequired,
};

export default ChatBox;

Enter fullscreen mode Exit fullscreen mode
  • The ChatBox component provides an input box and a button for users to send messages.
  • It uses useState to manage the message input state.
  • The handleSubmit function handles the form submission, invoking the onSubmit function with the message content.

Avatar Component

src/components/Avatar.jsx

import React from "react";
import PropTypes from "prop-types";

function Avatar({ src, alt, className }) {
  return <img loading="lazy" src={src} alt={alt} className={className} />;
}

Avatar.propTypes = {
  src: PropTypes.string.isRequired,
  alt: PropTypes.string.isRequired,
  className: PropTypes.string,
};

export default Avatar;

Enter fullscreen mode Exit fullscreen mode

The Avatar component renders an avatar image.

Step 8: Utility Functions

Chain Utility

src/utils/chain.js

import {
  RunnablePassthrough,
  RunnableSequence,
} from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { PromptTemplate } from "@langchain/core/prompts"
import { retriever } from './retriever';
import { combineDocuments } from './combineDocuments';

const openAIApiKey = import.meta.env.VITE_OPENAI_API_KEY;
const openAIUrl = import.meta.env.VITE_OPENAI_BASE_URL;

const llm = new ChatOpenAI({
  apiKey: openAIApiKey,
  configuration: {
    baseURL: openAIUrl,
  }
});

// A string holding the phrasing of the prompt
const standaloneQuestionTemplate = `Given some conversation history (if any) and a question,
convert the question to a standalone question.
conversation history: {conv_history}
question: {question}
standalone question:`;
// A prompt created using PromptTemplate and the fromTemplate method
const standaloneQuestionPrompt = PromptTemplate.fromTemplate(standaloneQuestionTemplate);

const answerTemplate = `You are a helpful and enthusiastic support bot who can answer a given question based on
the context provided and the conversation history. Try to find the answer in the context. If the answer is not given
in the context, find the answer in the conversation history if possible. If you really don't know the answer,
say "I'm sorry, I don't know the answer to that." And direct the questioner to email help@example.com.
Don't try to make up an answer. Always speak as if you were chatting to a friend.
context: {context}
conversation history: {conv_history}
question: {question}
answer: `;
const answerPrompt = PromptTemplate.fromTemplate(answerTemplate);

// Take the standaloneQuestionPrompt and PIPE the model
const standaloneQuestionChain = standaloneQuestionPrompt
  .pipe(llm)
  .pipe(new StringOutputParser());

const retrieverChain = RunnableSequence.from([
  prevResult => prevResult.standalone_question,
  retriever,
  combineDocuments,
]);

const answerChain = answerPrompt
  .pipe(llm)
  .pipe(new StringOutputParser());

const logConvHistory = async (input) => {
  console.log('Conversation History:', input.conv_history);
  return input;
}

const chain = RunnableSequence.from([
  {
    standalone_question: standaloneQuestionChain,
    original_input: new RunnablePassthrough(),
  },
  {
    context: retrieverChain,
    question: ({ original_input }) => original_input.question,
    conv_history: ({ original_input }) => original_input.conv_history,
  },
  logConvHistory,
  answerChain,
]);

export { chain };

Enter fullscreen mode Exit fullscreen mode

Here is a Flowchart

flowchart for chain

This file sets up a sequence of operations to handle the user's query and retrieve an appropriate response using LangChain, OpenAI, and a retriever for context.
- StandaloneQuestionChain: It is responsible for converting a user's query, which may depend on the context of previous conversation history, into a standalone question that can be understood without any prior context.
- RetrieverChain : It is used to retrieve relevant documents from the vector database that are pertinent to the standalone question generated by the StandaloneQuestionChain.
- LogConvHistory : It is a simple logging function used to print the conversation history to the console. This can be helpful for debugging and understanding the flow of conversation and how it influences the generated responses.
- AnswerChain : It is responsible for generating a response to the user's question based on the retrieved context and the conversation history.

Combine Documents Utility

src/utils/combineDocuments.js

export function combineDocuments(docs) {
  return docs.map((doc) => doc.pageContent).join('\n\n');
}
Enter fullscreen mode Exit fullscreen mode

This utility function combines multiple documents into a single string. This is useful for providing a unified context to the AI model.

Format Conversation History Utility

src/utils/formatConvHistory.js

export function formatConvHistory(messages) {
  return messages.map((message, i) => {
    if (i % 2 === 0) {
      return `Human: ${message}`
    } else {
      return `AI: ${message}`
    }
  }).join('\n')
}
Enter fullscreen mode Exit fullscreen mode

This utility function takes an array of messages and formats them into a conversation between a human and an AI.

Retriever Utility

src/utils/retriever.js

import { SupabaseVectorStore } from "@langchain/community/vectorstores/supabase";
import { OpenAIEmbeddings } from "@langchain/openai";
import { createClient } from '@supabase/supabase-js';

const sbUrl = import.meta.env.VITE_SUPABASE_BASE_URL;
const sbApiKey = import.meta.env.VITE_SUPABASE_API_KEY;
const openAIApiKey = import.meta.env.VITE_OPENAI_API_KEY;
const openAIUrl = import.meta.env.VITE_OPENAI_BASE_URL;

const client = createClient(sbUrl, sbApiKey);

const embeddings = new OpenAIEmbeddings({
  apiKey: openAIApiKey,
  configuration: {
    baseURL: openAIUrl
  }
});

const vectorStore = new SupabaseVectorStore(embeddings, {
  client,
  tableName: 'personal_infos',
  queryName: 'match_personal_infos',
});

const retriever = vectorStore.asRetriever();

export { retriever };

Enter fullscreen mode Exit fullscreen mode

This utility sets up a retriever using Supabase and OpenAI embeddings.
- SupabaseVectorStore: Initializes a vector store using Supabase.
- OpenAIEmbeddings: Creates embeddings using OpenAI.
- The retriever is then used to fetch relevant documents based on the user's query.

Running the Application

  1. Start the Development Server:

    npm run dev
    
  2. Access the Application:
    Open your browser and navigate to http://localhost:5173.

Conclusion

In this tutorial, you learned how to build an AI assistant using LangChain, OpenAI, and Supabase. We've covered the setup of a Vite project, integration of TailwindCSS, and the creation of React components and utility functions. You now have a functional AI assistant that can interact with users and provide responses based on the conversation history and context.

References

Top comments (0)