ساخت RAG Chatbot


در این راهنما، خواهید آموخت که چگونه می‌توانید یک برنامه چت‌بات مبتنی بر RAG (یا Retrieval-Augmented Generation)، ایجاد کنید.

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

RAG چیست؟

RAG مخفف عبارت Retrieval-Augmented Generation (یا تولید مبتنی‌بر بازیابی)، است. به زبان ساده، RAG فرآیند ارائه LLM با اطلاعات مشخص و مرتبط با پرامپت ورودی، می‌باشد.

چرا RAG مهم است؟

با وجود اینکه LLMها، قدرتمند هستند، اما توانایی آن‌ها در استدلال، فقط محدود به داده‌هایی است که بر روی آن‌ها، آموزش دیده‌اند. این محدودیت، زمانی نمایان می‌شود که از یک LLM اطلاعاتی درخواست شود که خارج از داده‌های آموزشی آن است؛ مانند داده‌های اختصاصی (در یک شرکت) یا اطلاعات عمومی که پس از تاریخ اتمام آموزش مدل، به‌وجود آمده‌اند. RAG این مشکل را حل می‌کند؛ به این صورت که ابتدا اطلاعات مرتبط با پرامپت را بازیابی کرده و سپس آن را به‌عنوان context در اختیار مدل، قرار می‌دهد.

به‌عنوان مثال، فرض کنید که از مدل می‌پرسید "غذای مورد علاقه من چیست؟". پاسخ مدل، احتمالاً مشابه زیر، خواهد بود:

کپی
**input**
What is my favorite food?

**generation**
I do not have access to personal information about individuals, including their
favorite foods.

جای تعجب نیست که مدل پاسخ این سؤال را نمی‌داند. اما فرض کنید که همراه با پرامپت شما، مدل، مقداری context اضافی نیز، دریافت کند:

کپی
**input**
Respond to the user's prompt using only the provided context.
user prompt: 'What is my favorite food?'
context: user loves ghormeh sabzi

**generation**
Your favorite food is ghormeh sabzi!

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

اما سؤال اینجاست: چگونه مدل این اطلاعات مرتبط را بازیابی می‌کند؟ پاسخ در مفهومی به نام Embedding، نهفته است.

شما می‌توانید از هر منبعی برای فراهم کردن context در برنامه RAG خود استفاده کنید (برای مثال، جستجوی گوگل). Embeddings و پایگاه‌داده‌های برداری (Vector Databasها) تنها یکی از روش‌های خاص بازیابی اطلاعات هستند که برای رسیدن به جستجوی معنایی (Semantic Search) به‌کار می‌روند.

Embedding

Embedding یا بردارسازی، روشی است برای نمایش کلمات، عبارات یا تصاویر، به‌صورت بردارهایی در فضایی با ابعاد بالا (High-Dimensional Space). در این فضا، واژه‌های مشابه از نظر معنایی، به یکدیگر نزدیک هستند و فاصله بین این کلمات، راهی برای اندازه‌گیری شباهت آن‌ها است.

در عمل، اگر شما کلمات "گربه" و "سگ" را بردارسازی کنید، انتظار دارید که در فضای برداری، در نزدیکی هم ترسیم شوند. فرآیند محاسبه شباهت بین دو بردار، شباهت کسینوسی (Cosine Similarity) نام دارد؛ که در آن، مقدار 1 نشان‌دهنده شباهت بسیار بالا، و مقدار 1- نشان‌دهنده تضاد کامل بین دو بردار است.

اگر این مفاهیم در ابتدا پیچیده به نظر می‌رسند، نیازی به نگرانی نیست. درک کلی و انتزاعی موضوع، برای شروع کاملاً کافی است.

همان‌طور که قبل‌تر اشاره شد، بردارسازی روشی است برای نمایش معنای مفهومی (semantic meaning) کلمات و عبارات. نکته‌ی مهم این است که هرچه ورودی بردارساز، بزرگ‌تر باشد، کیفیت بردار تولید‌شده ممکن است پایین‌تر بیاید. اکنون، سوال اصلی این است که چگونه می‌توانید محتوایی را که از یک عبارت ساده، خیلی طولانی‌تر است، به بردار تبدیل کنید؟

Chunking

Chunking یا تکه‌تکه‌سازی، فرآیندی است که در آن، یک منبع اطلاعاتی به بخش‌های کوچک‌تر، تقسیم می‌شود. روش‌های مختلفی برای انجام این کار وجود دارد و پیشنهاد می‌شود که بسته به نوع پروژه، فرایند‌های Chunking متفاوتی را آزمایش و مقایسه کنید، چرا که بهترین روش، با توجه به use case شما، می‌تواند متفاوت باشد. یکی از روش‌های ساده و رایج در Chunking (و روشی که در این راهنما از آن استفاده خواهید کرد)، تقسیم محتوای متنی بر اساس جملات است.

پس از آنکه منبع شما، به‌درستی قطعه‌قطعه شد، می‌توانید هر قطعه را بردارسازی کرده و سپس بردار به‌دست‌آمده را به‌همراه محتوای همان قطعه، در یک پایگاه‌داده ذخیره کنید. بردارها را می‌توان در هر پایگاه‌داده‌ای که از vectorها پشتیبانی می‌کند ذخیره کرد. در این آموزش، از Postgres به‌همراه افزونه‌ی pgvector استفاده خواهید کرد.

chunking process

همه‌چیز در کنار هم

با کنار هم قرار دادن تمام این موارد، می‌توان گفت که RAG فرآیندی است که در آن، مدل می‌تواند به اطلاعاتی فراتر از داده‌های آموزشی خود پاسخ دهد؛ این کار با بردارسازی پرسش کاربر، بازیابی قطعه‌هایی از منبع اطلاعاتی (چانک‌ها) که بیشترین شباهت معنایی را دارند، و سپس ارسال آن‌ها به‌همراه پرامپت اولیه به مدل به‌عنوان context، انجام می‌شود. اگر به مثال قبلی بازگردیم، یعنی زمانی که از مدل می‌پرسید "غذای مورد علاقه من چیه؟"، فرآیند آماده‌سازی پرامپت به این صورت خواهد بود:

all the rag process

با ارائه context مناسب و تنظیم هدف مدل به‌درستی، می‌توانید به‌خوبی از توانایی آن به‌عنوان یک ماشین استدلال‌گر (reasoning machine) بهره‌مند شوید.


راه‌اندازی پروژه

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

فناوری‌های استفاده‌شده در این پروژه، عبارتند از:

کلون ریپازیتوری

برای ساده‌تر کردن این آموزش، از یک ریپازیتوری آماده استفاده می‌کنیم که برخی از تنظیمات اولیه را از قبل دارد:

  • Drizzle ORM (دایرکتوری lib/db) شامل یک migration اولیه و یک اسکریپت برای انجام migrate (db:migrate)
  • یک schema ساده برای جدول resources (این جدول برای منبع اطلاعات یا همان source material به‌کار خواهد رفت)
  • یک Server Action برای ایجاد منبع (resource)

برای شروع، ابتدا ریپازیتوری اولیه را با دستور زیر کلون کنید:

کپی
git clone https://github.com/liara-cloud/ai-sdk-examples.git
cd ai-sdk-examples/rag-starter-raw

در مرحله اول، برای نصب وابستگی‌های پروژه، دستور زیر را اجرا کنید:

کپی
pnpm install

ایجاد دیتابیس

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

migrate دیتابیس

پس از ساخت دیتابیس، باید connection string آن را به‌عنوان متغیر محیطی، به برنامه اضافه کنید. برای این کار، با اجرای دستور زیر (در لینوکس)، یک کپی از فایل env.example. ایجاد کنید و نام آن را به env. تغییر دهید:

کپی
cp .env.example .env

یا اینکه، به‌سادگی نام فایل env.example. را به env. تغییر دهید. در ادامه، فایل env. جدید را باز کنید. باید متغیری را با نام DATABASE_URL مشاهده کنید. connection string دیتابیس خود را پس از علامت مساوی (=) در این قسمت، قرار دهید. با انجام کارهای فوق، اکنون می‌توانید اولین migration دیتابیس را اجرا کنید. دستور زیر را اجرا نمایید:

کپی
pnpm db:migrate

دستور فوق، ابتدا افزونه‌ی pgvector را به دیتابیس شما اضافه می‌کند. سپس یک جدول جدید برای اسکیمای resources ایجاد خواهد کرد که در فایل lib/db/schema/resources.ts تعریف شده است. این اسکیما شامل چهار ستون id، content، createdAt و updatedAt است.

اگر هنگام اجرای migration با خطا مواجه شدید، فایل migration خود را باز کنید (lib/db/migrations/0000_yielding_bloodaxe.sql)، خط اول آن را cut کنید (کپی کرده و سپس حذف کنید)، و آن خط را مستقیماً روی instance دیتابیس PostgreSQL خود، اجرا نمایید. اکنون، می‌توانید migration به‌روزشده را اجرا کنید.

baseUrl و کلید API لیارا

برای استفاده از این راهنما، در ابتدا باید، محصول هوش مصنوعی لیارا را تهیه کنید. البته می‌توانید از کلید خریداری شده OpenAI نیز استفاده کنید. در صورتی که قصد دارید مدل خود را از لیارا تهیه کنید؛ تنها به baseUrl محصول هوش مصنوعی لیارا و کلید API کنسول خود نیاز دارید.


ساخت (build)

بیایید یک فهرست از کارهایی که باید انجام شوند تهیه کنیم:

  • ایجاد یک جدول در دیتابیس برای ذخیره‌ی embeddingها
  • افزودن منطق chunking و ایجاد embeddingها هنگام ساخت resources
  • ایجاد یک چت‌بات
  • اتصال چت‌بات به ابزارهایی برای جستجو و ایجاد resources برای پایگاه دانش آن

ایجاد جدول Embeddings

در حال حاضر، برنامه‌ی شما دارای یک جدول به نام resources است که یک ستون به نام content برای ذخیره‌ی محتوا دارد. هر منبع (منبع اولیه‌ی اطلاعات) باید به قطعه‌هایی تقسیم شود؛ embedding آن تولید گردد و سپس ذخیره شود. در این مرحله، باید یک جدول به نام embeddings برای ذخیره‌ی این قطعه‌ها ایجاد کنیم.

یک فایل جدید به نام lib/db/schema/embeddings.ts ایجاد کرده و کد زیر را به آن اضافه کنید:

کپی
import { nanoid } from '@/lib/utils';
import { index, pgTable, text, varchar, vector } from 'drizzle-orm/pg-core';
import { resources } from './resources';

export const embeddings = pgTable(
  'embeddings',
  {
    id: varchar('id', { length: 191 })
      .primaryKey()
      .$defaultFn(() => nanoid()),
    resourceId: varchar('resource_id', { length: 191 }).references(
      () => resources.id,
      { onDelete: 'cascade' },
    ),
    content: text('content').notNull(),
    embedding: vector('embedding', { dimensions: 1536 }).notNull(),
  },
  table => ({
    embeddingIndex: index('embeddingIndex').using(
      'hnsw',
      table.embedding.op('vector_cosine_ops'),
    ),
  }),
);

این جدول شامل چهار ستون است:

  • id: شناسه‌ی یکتا
  • resourceId: کلید خارجی که به منبع کامل اطلاعات اشاره می‌کند
  • content: قطعه متنی (chunk) ساده شده
  • embedding: نمایش برداری (وکتوری) از بخش متنی ساده‌شده

برای انجام جستجوی شباهت (similarity search)، همچنین باید یک ایندکس (از نوع HNSW یا IVFFlat) بر روی ستون embedding ایجاد کنید تا عملکرد بهتری داشته باشید. برای اعمال تغییرات در پایگاه داده، دستور زیر را اجرا کنید:

کپی
pnpm db:push

افزودن منطق Embedding

اکنون که یک جدول برای ذخیره‌ی embeddingها دارید، نوبت نوشتن منطق لازم برای ایجاد embeddingها است. برای این کار، ابتدا با استفاده از دستور زیر (در لینوکس)، فایل موردنیاز را ایجاد کنید:

کپی
mkdir lib/ai && touch lib/ai/embedding.ts

یا اینکه کافیست به سادگی، در دایرکتوری lib، یک دایرکتوری جدید به نام ai ایجاد کنید و سپس یک فایل جدید به نام embedding.ts در آن دایرکتوری بسازید.

تولید chunkها

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

کپی
const generateChunks = (input: string): string[] => {
  return input
    .trim()
    .split('.')
    .filter(i => i !== '');
};

تابع فوق، یک رشته‌ی متنی را به‌عنوان ورودی می‌گیرد و جملات درون آن را با استفاده از نقطه (.)، از هم جدا می‌کند. سپس، هر عضو خالی آرایه را حذف می‌کند و در نهایت، آرایه‌ای از رشته‌های متنی برمی‌گرداند. لازم به ذکر است که در پروژه‌های مختلف، تکنیک‌های chunking متفاوت، ممکن است عملکرد بهتری داشته باشند، بنابراین آزمایش با روش‌های گوناگون پیشنهاد می‌شود.

نصب AI SDK

برای ایجاد embeddingها از AI SDK استفاده خواهیم کرد. این کار به چند ماژول اضافی نیاز دارد. برای نصب آن‌ها، دستور زیر را اجرا کنید:

کپی
pnpm add ai@beta @ai-sdk/react@beta @ai-sdk/openai@beta

دستور فوق، AI SDK، هوک‌های @ai-sdk/react و @ai-sdk/openai (برای اتصال به مدل) را به پروژه‌ی شما اضافه می‌کند.

با AI SDK و محصول هوش مصنوعی لیارا، شما می‌توانید به LLMهای متنوع و متفاوتی دسترسی داشته باشید؛ آن هم تنها با یک خط تغییر در کد.

تولید embeddingها

بیایید تابعی برای تولید embeddingها اضافه کنیم. کد زیر را در فایل lib/ai/embedding.ts خود قرار دهید:

کپی
import { embedMany } from 'ai';
import { openai } from '@ai-sdk/openai';

const embeddingModel = openai.embedding('text-embedding-ada-002');

const generateChunks = (input: string): string[] => {
  return input
    .trim()
    .split('.')
    .filter(i => i !== '');
};

export const generateEmbeddings = async (
  value: string,
): Promise<Array<{ embedding: number[]; content: string }>> => {
  const chunks = generateChunks(value);
  const { embeddings } = await embedMany({
    model: embeddingModel,
    values: chunks,
  });
  return embeddings.map((e, i) => ({ content: chunks[i], embedding: e }));
};

در قطعه کد فوق، ابتدا، مدلی که برای تولید embeddingها می‌خواهید استفاده کنید تعریف می‌شود. در این مثال، از مدل بردارساز OpenAI به نام text-embedding-ada-002 استفاده شده است.

در ادامه، یک تابع asynchronous به نام generateEmbeddings تعریف می‌شود. این تابع، داده‌ی ورودی (که در اینجا value نام دارد) را دریافت می‌کند و یک Promise از آرایه‌ای از objectها را برمی‌گرداند. هر object شامل یک embedding و محتوای مربوط به آن است. درون این تابع، ابتدا ورودی به یک‌سری قطعه (chunk) تقسیم می‌شود. سپس این قطعه‌ها به تابع embedMany، که از AI SDK وارد شده، ارسال می‌شوند؛ این تابع، embedding مربوط به هر chunk را تولید می‌کند. در نهایت، روی embeddingها پیمایش (map) انجام می‌شود تا خروجی نهایی در قالبی آماده برای ذخیره‌سازی در دیتابیس، تولید شود.

به‌روزرسانی Server Action

فایل lib/actions/resources.ts را باز کنید. این فایل حاوی تنها یک تابع به نام createResource است که همان‌طور که از نامش پیداست، برای ایجاد یک resource جدید مورد استفاده قرار می‌گیرد.

کپی
'use server';

import {
  NewResourceParams,
  insertResourceSchema,
  resources,
} from '@/lib/db/schema/resources';
import { db } from '../db';

export const createResource = async (input: NewResourceParams) => {
  try {
    const { content } = insertResourceSchema.parse(input);

    const [resource] = await db
      .insert(resources)
      .values({ content })
      .returning();

    return 'Resource successfully created.';
  } catch (e) {
    if (e instanceof Error)
      return e.message.length > 0 ? e.message : 'Error, please try again.';
  }
};

تابع تعریف‌شده در قطعه کد فوق، یک Server Action محسوب می‌شود، که با دستور ;'use server' در ابتدای فایل مشخص شده است. به همین دلیل، این تابع را می‌توان از هر نقطه‌ای در برنامه‌ی Next.js فراخوانی کرد. عملکرد این تابع به این صورت است که یک ورودی دریافت می‌کند، آن را با استفاده از یک اسکیما‌ی Zod اعتبارسنجی می‌کند تا اطمینان حاصل شود که ساختار ورودی مطابق با استاندارد مورد انتظار است، در نهایت، یک resource جدید در دیتابیس ایجاد می‌کند.

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

فایل را با قطعه کد زیر، آپدیت کنید:

کپی
'use server';

import {
  NewResourceParams,
  insertResourceSchema,
  resources,
} from '@/lib/db/schema/resources';
import { db } from '../db';
import { generateEmbeddings } from '../ai/embedding';
import { embeddings as embeddingsTable } from '../db/schema/embeddings';

export const createResource = async (input: NewResourceParams) => {
  try {
    const { content } = insertResourceSchema.parse(input);

    const [resource] = await db
      .insert(resources)
      .values({ content })
      .returning();

    const embeddings = await generateEmbeddings(content);
    await db.insert(embeddingsTable).values(
      embeddings.map(embedding => ({
        resourceId: resource.id,
        ...embedding,
      })),
    );

    return 'Resource successfully created and embedded.';
  } catch (error) {
    return error instanceof Error && error.message.length > 0
      ? error.message
      : 'Error, please try again.';
  }
};

در قطعه کد فوق، ابتدا، تابع generateEmbeddings که در مرحله‌ی قبل ایجاد کرده‌اید؛ فراخوانی می‌شود و محتوای منبع (content)، به آن داده می‌شود. پس از آنکه embeddingها متن را دریافت کردند (که در اینجا با e نشان داده شده‌اند)، در پایگاه داده ذخیره می‌شوند؛ به‌طوری که هر embedding در کنار resourceId مرتبط با خودش، ذخیره می‌شود.