برنامه نویسی

نحوه ساخت یک چت بات متنی با LangChain و PostgreSQL + Drizzle ORM

آیا تا به حال خواسته اید یک ربات چت داشته باشید که بتواند زمینه یک مکالمه و یک سند را درک کند؟ به عنوان مثال، تصور کنید در حال خواندن یک سند در مورد نحوه ساخت ربات چت هستید و در مورد مرحله خاصی سوال دارید. می‌توانید این سؤال را از ربات چت خود بپرسید، و بدون نیاز به کپی و پیست کردن چیزی، می‌تواند به شما پاسخ دهد.

این همان چیزی است که چت های متنی در مورد آن هستند. آنها به شما امکان می دهند با یک چت بات یک مکالمه طبیعی داشته باشید، حتی اگر چت بات با موضوع گفتگو آشنا نباشد.

چت های متنی با ترکیبی از فناوری ها از جمله LangChain، PostgreSQL و Drizzle امکان پذیر می شود.

  • LangChain چارچوبی است که تعامل با مدل های زبان را آسان می کند.
  • PostgreSQL یک پایگاه داده است که می تواند برای ذخیره اسناد استفاده شود.
  • Drizzle یک ORM است که می تواند برای پرس و جو اسناد در PostgreSQL استفاده شود.

در این آموزش به شما نشان خواهیم داد که چگونه با استفاده از این فناوری ها یک چت بات متنی بسازید. ما با ایجاد یک ربات چت ساده شروع خواهیم کرد که می تواند به سؤالات مربوط به اسناد پاسخ دهد. سپس، ما به شما نشان خواهیم داد که چگونه از LangChain برای تعامل با یک مدل زبان، از PostgreSQL برای ذخیره اسناد و از Drizzle برای درخواست اسناد استفاده کنید.

در پایان این آموزش، شما ایده ای در مورد چگونگی ساخت یک ربات گفتگوی متنی خواهید داشت که می تواند به سوالات مربوط به اسناد پاسخ دهد. همچنین درک بهتری از نحوه استفاده از این فناوری‌ها برای ساخت ربات‌های گفتگوی متنی خواهید داشت.

در مورد من می خواستم رابط کاربری یک فایل PDF را بپذیرد و آن را در پایگاه داده آپلود کند و به این صورت بود:

پیش نمایش ربات چت متنی


بدون هیچ مقدمه ای، بیایید شروع کنیم!

برای این آموزش شما باید با Typescript، PostgreSQL و Drizzle (یا ORM های مشابه مانند Prisma) و احتمالا NextJS آشنا باشید، اما می توانید این آموزش را با هر چارچوب دیگری تطبیق دهید زیرا ما فقط بر روی Backend تمرکز خواهیم کرد.

من نمی خواهم این پست را با افزودن کدهای ظاهری خیلی طولانی کنم. من از این پروژه به‌عنوان پایه‌ای برای فرانت‌اند استفاده کردم و باطن را برای پشتیبانی از آپلود و پرس‌وجو از اسناد به کار بردم تا با یک ربات چت آگاه از زمینه کار کند.

مدل Drizzle ما حاوی یک سند والد است LangChainDocs و سند فرزند Docs. سند والد حاوی نام سند و سند فرزند حاوی فراداده و محتوای سند خواهد بود.

// your-drizzle-model.ts
import { relations } from 'drizzle-orm';
import { pgTable, text, varchar } from 'drizzle-orm/pg-core';

export const langChainDocs = pgTable('LangChainDocs', {
  id: varchar('id').primaryKey(),
  createdAt: text('createdAt'),
  name: text('name'),
  nameSpace: text('nameSpace'),
});

export const langChainDocRelations = relations(langChainDocs, ({ many }) => ({
  docs: many(docs),
}));

export const docs = pgTable('Docs', {
  id: varchar('id').primaryKey(),
  createdAt: text('createdAt'),
  metadata: text('metadata'),
  pageContent: text('pageContent'),
  name: text('name'),
  langChainDocsId: text('langChainDocsId'),
});

export const docsRelations = relations(docs, ({ one }) => ({
  langChainDocs: one(langChainDocs, {
    fields: [docs.langChainDocsId],
    references: [langChainDocs.id],
  }),
}));

// your-drizzle-db.ts
export const drizzleDb = drizzle(client, { schema });
// more of how to setup drizzle in https://drizzle.dev/docs/#getting-started
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

سپس مسیری خواهیم داشت که تنها در صورتی که این سند قبلاً وجود نداشته باشد، یک سند را در پایگاه داده ما آپلود می کند. ما به دلایل واضح نمی خواهیم اسناد تکراری در پایگاه داده ما وجود داشته باشد.

درخواست از مشتری چیزی شبیه به این خواهد بود:

const formData = new FormData();

// some existing document of the type of File
formData.append('file', file);

const response = await fetch('/api/upload', {
  method: 'POST',
  // you'll probably have to add the multipart/form-data header
  body: formData,
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

سپس در backend فایل را با کتابخانه چند حزبی تجزیه می کنیم، این نام فایل و مسیر فایل را در حافظه محلی سرور به ما می دهد.

// api/upload.ts
import { Form } from 'multiparty';

export default async function handler(
  req: ApFDataRequest,
  res: NextApiResponse,
) {
  const form = new Form();
  const formData = await new Promise<FData>((resolve, reject) => {
    form.parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }

      const file = files.file[0];
      resolve({ file });
    });
  });

  const fileName = formData.file.originalFilename;
  const filePath = formData.file.path;
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

پس از داشتن فایل تجزیه شده، از پایگاه داده پرس و جو می کنیم تا ببینیم آیا سند از قبل وجود دارد یا خیر. اگر وجود نداشته باشد، آن را در پایگاه داده آپلود می کنیم و نام سند را به مشتری برمی گردانیم.

// api/upload.ts
import { getExistingDocs } from 'your-backend';

const DBDocs = await getExistingDocs(fileName);

// somewhere in your backend or directly in the api/upload.ts file
export const getExistingDocs = async (fileName: string) => {
  const document = await drizzleDb.query.langChainDocs.findMany({
    where: eq(schema.langChainDocs.name, fileName),
    with: {
      docs: true,
    },
  });

  return document;
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

دوباره در فایل api/upload.ts بررسی می کنیم document.length برای دیدن اینکه آیا سند از قبل در پایگاه داده وجود دارد یا خیر.

const DBDocs = await getExistingDocs(fileName);
const fileExistsInDB = DBDocs.length > 0;

if (!fileExistsInDB) {
  // upload the document to the database
} else {
  // return the document name to the client
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون زمان آن رسیده است که فایل را به گونه ای تجزیه و تحلیل کنیم که Langchain بتواند آن را درک کند و آن را در پایگاه داده آپلود کند.

// somewhere in your backend or directly in the api/upload.ts file
import { Document } from 'langchain/document';
import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

const getPdfText = async (
  // the path of the file in the server local storage
  filePath: string
): Promise<Document<Record<string, any>>[]> => {
  const loader = new PDFLoader(filePath);

  const pdf = await loader.load();

  // split into chunks
  const textSplitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1000,
    chunkOverlap: 200,
  });

  // this outputs an array of the type of Document objects
  // https://docs.langchain.com/docs/components/schema/document
  const docs = await textSplitter.splitDocuments(pdf);

  return docs;
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

با سند تجزیه شده، اکنون می توانیم آن را با استفاده از Drizzle ORM در پایگاه داده آپلود کنیم

const drizzleInsertDocs = async (
  docsToUpload: Document[],
  fileName: string
) => {
  await drizzleDb.transaction(async () => {
    const newDocId = randomUUID();

    await drizzleDb
      .insert(langChainDocs)
      .values({
        id: newDocId,
        name: fileName,
        nameSpace: fileName,
      })
      .returning();

    await drizzleDb.insert(docs).values(
      docsToUpload.map((doc) => ({
        id: randomUUID(),
        name: fileName,
        // metadata is a JSON object thus we need to stringify it
        metadata: JSON.stringify(doc.metadata),
        pageContent: doc.pageContent,
        langChainDocsId: newDocId,
      }))
    );
  });
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

و به این صورت احضار می شوند:

export const langchainUploadDocs = async (
  filePath: string,
  fileName: string
) => {
  const docs = await getPdfText(filePath);

  await drizzleInsertDocs(docs, fileName);
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در مجموع فایل api/upload.ts به شکل زیر خواهد بود:

import type { NextApiRequest, NextApiResponse } from 'next';
import { Form } from 'multiparty';

import { langchainUploadDocs } from '@/utils/langchain';
import { getErrorMessage } from '@/utils/misc';
import { getExistingDocs } from '@/utils/drizzle';

export const config = {
  api: {
    bodyParser: false,
  },
};

interface FData {
  file: {
    fieldName: string;
    originalFilename: string;
    path: string;
    headers: {
      [key: string]: string;
    };
    size: number;
  };
}

interface ApFDataRequest extends NextApiRequest {
  body: FData;
}

export type UploadResponse = {
  fileExistsInDB: boolean;
  nameSpace: string;
};

export default async function handler(
  req: ApFDataRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    res.status(405).json({ error: 'Method not allowed' });
    return;
  }
  const form = new Form();
  const formData = await new Promise<FData>((resolve, reject) => {
    form.parse(req, (err, fields, files) => {
      if (err) {
        reject(err);
        return;
      }

      const file = files.file[0];
      resolve({ file });
    });
  });

  const fileName = formData.file.originalFilename;
  const filePath = formData.file.path;

  try {
    const DBDocs = await getExistingDocs(fileName);
    const fileExistsInDB = DBDocs.length > 0;

    if (!fileExistsInDB) {
      try {
        await langchainUploadDocs(filePath, fileName);
      } catch (error) {
        const errMsg = getErrorMessage(error);
        res.status(500).json({ error: errMsg });
        return;
      }
    }

    const resData: UploadResponse = {
      fileExistsInDB: !!fileExistsInDB,
      nameSpace: fileName,
    };

    res.status(200).json(resData);
  } catch (error) {
    const errMsg = getErrorMessage(error);
    res.status(500).json({ error: errMsg });
    return;
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

لطفاً به یاد داشته باشید که من از NextJS routes api استفاده می کنم، بنابراین شما باید این کد را با چارچوب انتخابی خود تطبیق دهید.

عالی است، ما اکنون یک فایل در پایگاه داده خود آپلود کرده ایم. اکنون زمان آن است که برای دریافت سند و ابرداده سند از پایگاه داده پرس و جو کنید و با استفاده از ابزارهایی که Langchain در اختیار ما قرار می دهد، با چت بات خود گفتگو کنید.

بازگشت به مشتری، ما پاسخی از سرور با نام سند دریافت کرده‌ایم. ما از این نام برای فراخوانی استفاده خواهیم کرد api/chat مسیری برای گفتگوی متنی واقعی

مشتری درخواستی مانند زیر را به سرور ارسال می کند:

// shape of the request
interface ReqBody {
  question: string;
  history: Array<Array<string>>;
  nameSpace: string;
}

// 1st iteration of the conversation
// question: 'Please give me an overview of the document',
const req = {
  question: 'Please give me an overview of the document',
  history: [],
  nameSpace: 'tasty-cakes.pdf',
};

// 2nd iteration of the conversation
const req = {
  question: 'Do they have chocolate?',
  history: [
    [
      'Please give me an overview of the document',
      'The document is about tasty cakes',
    ],
  ],
  nameSpace: 'tasty-cakes.pdf',
};

// 3rd iteration of the conversation
const req = {
  question: 'Do they have vanilla?',
  history: [
    [
      'Please give me an overview of the document',
      'The document is about tasty cakes',
    ],
    ['Do they have chocolate?', 'Yes, they have chocolate'],
  ],
  nameSpace: 'tasty-cakes.pdf',
};

// and so on...
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

که به صورت زیر به سرور ارسال می شود:

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(req),
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

سپس به ما بازگشت api/chat.ts فایل ما درخواست را تجزیه می کنیم و پایگاه داده را پرس و جو می کنیم تا سند و ابرداده سند را دریافت کنیم.

// use the nameSpace to query the database with our getExistingDocs function
const DBDocs = await getExistingDocs(nameSpace);

// we must arrange them in a way that Langchain can understand using the Document class
import { Document } from 'langchain/document';

const documents = sqlDocs[0].docs.map(
  (doc) =>
    new Document({
      metadata: JSON.parse(doc.metadata as string),
      pageContent: doc.pageContent as string,
    })
);
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

ما همچنین باید تاریخچه چت را به گونه ای ترتیب دهیم که Langchain بتواند درک کند:

// api/chat.ts
import {
  AIChatMessage,
  BaseChatMessage,
  HumanChatMessage,
} from 'langchain/schema';

const chatHistory: BaseChatMessage[] = [];
history?.forEach((_, idx) => {
  // first message is always human message
  chatHistory.push(new HumanChatMessage(history[idx][0]));
  // second message is always AI response
  chatHistory.push(new AIChatMessage(history[idx][1]));
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

ما از کتابخانه HNSWLib برای جاسازی محلی اسناد در یک فضای برداری استفاده خواهیم کرد. این به ما امکان می‌دهد شبیه‌ترین سند را با سؤالی که می‌پرسیم از پایگاه داده جستجو کنیم.

// api/chat.ts
const HNSWStore = await HNSWLib.fromDocuments(
  documents,
  new OpenAIEmbeddings()
);
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

چیزی به نام زنجیره ایجاد خواهیم کرد (توضیحات کوچک اضافه کنید):

// somewhere in your backend or directly in the api/chat.ts file
import { OpenAI } from 'langchain/llms/openai';
import { ConversationalRetrievalQAChain } from 'langchain/chains';
import { VectorStore } from 'langchain/dist/vectorstores/base';

const CONDENSE_PROMPT = `Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question.

Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:`;

const QA_PROMPT = `You are a helpful AI assistant. Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say you don't know. DO NOT try to make up an answer.
If the question is not related to the context, politely respond that you are tuned to only answer questions that are related to the context.

{context}

Question: {question}
Helpful answer in markdown:`;

export const makeChain = async (vectorStore: VectorStore) => {
  const model = new OpenAI({
    temperature: 0.9, // increase temepreature to get more creative answers
    modelName: 'gpt-3.5-turbo', //change this to gpt-4 if you have access
    openAIApiKey: process.env.OPENAI_API_KEY,
  });

  return ConversationalRetrievalQAChain.fromLLM(
    model,
    vectorStore.asRetriever(),
    {
      qaTemplate: QA_PROMPT,
      questionGeneratorChainOptions: { template: CONDENSE_PROMPT },
      returnSourceDocuments: true, // optional
    }
  );
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

و ما زنجیره را به این صورت فرا می‌خوانیم:

const HNSWStore = await HNSWLib.fromDocuments(
  documents,
  new OpenAIEmbeddings()
);

const chain = await makeChain(HNSWStore);

// Sanitize the question since OpenAI recommends replacing newlines with spaces for best results
const sanitizedQuestion = question.trim().replaceAll('\n', ' ');
const response = await chain.call({
  question: sanitizedQuestion,
  chat_history: chatHistory || [],
});
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

پاسخ یک رشته متن و آرایه ای از اشیاء Document را برمی گرداند.

type Response = {
  // 'The document is about tasty cakes'
  answer: string;
  sourceDocuments: Document[];
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

همین بود. بابت طولانی بودن پست عذرخواهی می کنم، اما می خواستم مطمئن شوم که تمام مراحل ساخت این کار را انجام داده ام.

در اینجا کد منبع یک نمونه کار آمده است در صورتی که می خواهید با آن بازی کنید، جزئیات بیشتر را بررسی کنید و -امیدوارم- آن را بهبود بخشید!

نتیجه

در این آموزش، ما به شما نشان دادیم که چگونه با استفاده از LangChain، PostgreSQL و Drizzle یک چت بات متنی بسازید. ما با ایجاد یک ربات چت ساده شروع کردیم که می توانست به سوالات در مورد اسناد پاسخ دهد. سپس، ما به شما نشان دادیم که چگونه از LangChain برای تعامل با یک مدل زبان، PostgreSQL برای ذخیره اسناد، و Drizzle برای درخواست اسناد استفاده کنید.

در پایان این آموزش، شما یک ربات گفتگوی متنی دارید که می تواند به سوالات مربوط به اسناد پاسخ دهد. شما همچنین درک بهتری از نحوه استفاده از این فناوری‌ها برای ساخت ربات‌های گفتگوی متنی دارید.

در اینجا برخی از مراحل بعدی است که می توانید انجام دهید:

توانایی ربات چت برای پاسخ دادن به سوالات را بهبود بخشید. شما می توانید این کار را با آموزش مدل زبان بر روی مجموعه داده بزرگتری از اسناد انجام دهید.
قابلیت های بیشتری را به چت بات اضافه کنید. برای مثال، می‌توانید توانایی تولید خلاصه‌ای از اسناد یا توانایی پاسخ دادن به سؤالات باز را اضافه کنید.
ربات چت را در یک محیط تولید مستقر کنید. این به شما امکان می دهد ربات چت را با دیگران به اشتراک بگذارید و بازخورد جمع آوری کنید.

امیدوارم شما از این آموزش لذت برده باشید!

جایزه

پرس و جو و به روز رسانی با Prisma ORM

// schema.prisma.ts

model LangChainDocs {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  name      String
  nameSpace String
  docs      Docs[]
}

model Docs {
  id              String        @id @default(uuid())
  createdAt       DateTime      @default(now())
  metadata        String // json string
  pageContent     String
  name            String
  docs            LangChainDocs @relation(fields: [langChainDocsId], references: [id])
  langChainDocsId String
}

// for uploading documents to the database
const prismaInsertDocs = async (docsToUpload: Document[], fileName: string) => {
  await prisma.langChainDocs.create({
    data: {
      name: fileName,
      nameSpace: fileName,
      docs: {
        create: docsToUpload.map((doc) => ({
          name: fileName,
          metadata: JSON.stringify(doc.metadata),
          pageContent: doc.pageContent,
        })),
      },
    },
  });
};

// for querying the database
export const getDocumentsFromDB = async (fileName: string) => {
  const docs = await prisma.langChainDocs.findFirst({
    where: {
      name: fileName,
    },
    include: {
      docs: true,
    },
  });

  return docs;
};
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

منابع:

همانطور که اسحاق نیوتن گفت: “اگر من بیشتر دیده ام، با ایستادن بر روی شانه های غول ها است.”

بیشتر قسمت ظاهری مانند پروژه اصلی که من برای شروع با Langchain استفاده کردم و تعدادی تغییرات را ایجاد کردم تا با استفاده از PostgreSQL و Drizzle ORM کار کند، حفظ شده است.

عکس از Juri Gianfrancesco در Unsplash

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا