مقدمه — چرا RAG و ChromaDB؟
در دنیای امروز که حجم متن و سوالات تخصصی بسیار زیاد است، استفاده از روشهای سنتی فقط با TF-IDF یا تطابق برداری سطحی اشتباه است اگر بخواهیم پاسخهای دقیق، مستند و قابل اعتماد ارائه دهیم. RAG (Retrieval-Augmented Generation) ترکیبی از دو جهان است: بازیابی (تاچپذیری به منابع واقعی) و تولید (تولید پاسخ طبیعی با کمک مدلهای زبانی). وقتی این ترکیب را با یک دیتابیس برداری سریع و مقیاسپذیر مثل ChromaDB به کار میبریم، ربات ما هم پاسخ دقیق میدهد، هم میتواند منابع را ارجاع دهد و هم از حافظه برداری برای جستجوی مشابهت معنایی استفاده میکند. در این مقاله قدمبهقدم یک پیادهسازی عملی برای دیتاست فارسی (سؤالات و پاسخهای شرعی) ارائه میکنم؛ کد را با هم بازنویسی میکنیم تا از TF-IDF سنتی به RAG مبتنی بر ChromaDB برویم.
RAG چیست؟ مفاهیم کلیدی و مزایا
RAG یعنی بازیابی تقویتشده با تولید. ایده ساده و قدرتمند است: برای پاسخ دادن به یک سؤال ابتدا متنهایی مرتبط را از یک بانک دانش بازیابی میکنیم (retrieval)، سپس این متنها را بهعنوان «شواهد» یا «زمینه» به یک مدل زبانی میدهیم تا پاسخ نهایی را تولید کند. مزایا: کاهش هالوسینیشن (اگر شواهد درست باشند)، قابلیت ارجاع به منابع، و بهروزپذیری آسان با اضافه/حذف سند بدون نیاز به fine-tune مدل.
ChromaDB چیست و چرا باید از آن استفاده کنیم؟
ChromaDB یک موتور ذخیره و بازیابی برداری است که برای نگهداری embeddings طراحی شده. مزایای آن: سرعت بالا در جستجوی نزدیکترین بردارها، پشتیبانی از متادیتا برای هر سند، و سادگی استفاده در پایتون. با ChromaDB میتوان میلیاردها بردار نگه داشت (بستگی به پیادهسازی توزیع و هاستینگ) و بازیابی بلادرنگ انجام داد.
معماری کلی یک ربات پاسخگو مبتنی بر RAG
خزانه دانش (Knowledge Base)
مجموعهای از متنها (سوالها، پاسخها، مقالات، فتواها) که قبل از اندکس شدن پاکسازی و چانک شدهاند. هر چانک میتواند متادیتا داشته باشد (منبع، تاریخ، نوع فقهی و ...).
اندکس بردارها (Vector Index)
پس از محاسبه embedding برای هر چانک، آنها را در ChromaDB ذخیره میکنیم. هر رکورد: بردار + متن + متادیتا.
ماژول تولید (Generator / LLM)
یک مدل زبانی (مثلاً OpenAI ChatCompletions، یا یک LLM محلی) که به عنوان آخرین مرحله عمل میکند: با گرفتن سؤال کاربر و متنهای بازیابیشده، پاسخ نهایی را تولید میکند.
پیشنیازها و کتابخانههای مورد نیاز
در این پروژه ما از موارد زیر استفاده خواهیم کرد (در پایتون):
-
pandas, numpy (برای مدیریت داده)
-
hazm (پیشپردازش فارسی)
-
sentence-transformers (محاسبه embeddings)
-
chromadb (اندکس برداری و بازیابی)
-
openai یا هر LLM دلخواه برای بخش تولید (میتوانید از API یا LLM محلی استفاده کنید)
-
joblib (ذخیره مدلها)
نمونه دستور نصب:
pip install pandas numpy hazm sentence-transformers chromadb openai joblib
آمادهسازی دیتاست (خواندن CSV و پاکسازی اولیه)
ابتدا CSV را میخوانیم و ستونهای question و answer را استخراج میکنیم. اگر دادههای شما فرمت متفاوتی دارند، باید ستونها را تطبیق دهید. سپس هر رکورد را به چند چانک تقسیم میکنیم تا طول متن مناسب برای embedding و تولید فراهم شود (چانکها معمولاً 200-500 توکن/کلمه هستند).
پیشپردازش پیشرفته فارسی با Hazm
نرمالسازی و توکنسازی
با Hazm متن فارسی را نرمال میکنیم (حذف نیمفاصله مشکلزا، همگامسازی حروف)، سپس توکن میکنیم و کلمات توقف را حذف میکنیم. اما دقت کنید: حذف بیشازحد stopwords ممکن است معنای جملات کوتاه شرعی را بههم بریزد؛ بنابراین برای متون مذهبی باید محتاط باشیم و بعضی واژههای کلیدی را از لیست حذفشدنیها مستثنی کنیم.
تقسیم سند به چانک (chunking)
هر پاسخ بلند را به قطعات معنادار تقسیم کنید؛ نگه داشتن مرزهای جملهای بهتر از بریدن خام است. هر چانک همراه با متادیتا ذخیره شود (مثلاً source_id, original_question_id, chunk_index).
ایجاد embeddings با Sentence-Transformers
برای فارسی میتوان از مدلهای چندزبانه یا فارسی-بهینهشده استفاده کرد (مثلاً paraphrase-multilingual-MiniLM-L12-v2 یا هر مدل فارسی مشابه). نکته: embeddings باید به صورت نرمالیزه نگهداری شوند تا شباهت کسینوسی به راحتی محاسبه شود.
ساخت و ذخیره ChromaDB و وارد کردن دادهها
پس از محاسبه بردارها، آنها را با متادیتا وارد Chroma میزبان محلی یا ابری میکنیم. Chroma امکان persist کردن اندیس به دیسک را دارد؛ پس در هر بار اجرا نیازی به بازسازی کامل نیست.
استراتژی بازیابی: انتخاب top_k و فیلتر ایمنی
معمولاً top_k=3 یا 5 مناسب است؛ اما برای پاسخهای دقیق شرعی بهتر است نتایج بیشتری بازیابی کرده و سپس با الگوریتمی وزندهی کنید. همچنین باید فیلترهای ایمنی و تقطیع نمره (score threshold) اعمال شود تا پاسخهای بیربط یا با نمره پایین رها شوند.
ترکیب اطلاعات بازیابیشده و تولید پاسخ (RAG fusion)
دو روش مرسوم:
-
RAG-پایپلاین (retrieve-then-generate): متنهای بازیابیشده به prompt اضافه میشوند و LLM پاسخ میدهد.
-
RAG-قواعدی (retrieve-and-copy): اگر شباهت خیلی بالا باشد، پاسخ بازیابیشده مستقیماً به کاربر داده میشود (با ذکر منبع).
برای متون شرعی پیشنهاد میکنم ترکیب دو روش: اگر نمره شبیهسازی بالاتر از e.g. 0.85 بود پاسخ بازیابیشده را مستقیماً ارائه بده و در غیر این صورت از LLM بخواه متن فشرده و سندمحور بسازد و مراجع را ذکر کند.
کد کامل — نسخه ارتقاءیافته (با ChromaDB و RAG)
توجه: در این کد، ما کد پایه شما را بازنویسی میکنیم. این نسخه از ChromaDB برای اندکس برداری، از sentence-transformers برای embeddings و از OpenAI برای تولید استفاده میکند. اگر نمیخواهید از OpenAI استفاده کنید، در بخش تولید میتوانید مدل محلی قرار دهید.
# ==================================================
# پیادهسازی RAG با ChromaDB برای دیتاست فارسی
# ==================================================
import os
import re
import pandas as pd
import numpy as np
from hazm import Normalizer, word_tokenize, Lemmatizer, stopwords_list, sent_tokenize
from sentence_transformers import SentenceTransformer
import chromadb
from chromadb.config import Settings
from chromadb.utils import embedding_functions
import joblib
import json
# اگر از OpenAI استفاده میکنید:
import openai
# ================ پیکربندی ================
# تنظیم کلید OpenAI در صورت نیاز (یا از مدل محلی استفاده کنید)
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
openai.api_key = OPENAI_API_KEY
# مسیر فایل CSV
CSV_PATH = "persian_religious_qa.csv"
# پارامترهای RAG
EMBEDDING_MODEL_NAME = "paraphrase-multilingual-MiniLM-L12-v2" # یا مدل فارسی مناسب
CHROMA_PERSIST_DIR = "./chroma_persist"
TOP_K = 4
SCORE_THRESHOLD = 0.2 # مقدار دلخواه برای فیلتر نتایج بسیار ضعیف
# ================ آمادهسازی Hazm ================
normalizer = Normalizer()
lemmatizer = Lemmatizer()
stopwords = set(stopwords_list())
def clean_text(text: str) -> str:
if not isinstance(text, str):
text = str(text)
text = normalizer.normalize(text)
# نگه داشتن حروف فارسی و علائم نگارشی پایه
text = re.sub(r"[^\u0600-\u06FF\s،؟\.\،\:\-\n]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def preprocess_tokens(text: str):
text = clean_text(text)
tokens = word_tokenize(text)
tokens = [lemmatizer.lemmatize(t) for t in tokens if t not in stopwords and len(t) > 1]
return " ".join(tokens)
# ================ خواندن و چانکسازی دیتاست ================
df = pd.read_csv(CSV_PATH)
df["question"] = df["question"].astype(str)
df["answer"] = df["answer"].astype(str)
# تابع تقسیم به چانکهای معنادار بر مبنای جمله
def chunk_text(text, max_sentences=4):
sents = sent_tokenize(text)
chunks = []
cur = []
for s in sents:
cur.append(s)
if len(cur) >= max_sentences:
chunks.append(" ".join(cur))
cur = []
if cur:
chunks.append(" ".join(cur))
return chunks
documents = []
for idx, row in df.iterrows():
q = clean_text(row["question"])
a = clean_text(row["answer"])
# هر سوال-پاسخ را به مجموعه چانک تبدیل میکنیم (از پاسخها نیز چانک میسازیم)
a_chunks = chunk_text(a, max_sentences=3)
for i, chunk in enumerate(a_chunks):
documents.append({
"id": f"{idx}_a_{i}",
"question": q,
"text": chunk,
"source": f"csv_row_{idx}",
"orig_index": idx,
"chunk_index": i
})
print(f"تعداد چانکها: {len(documents)}")
# ================ ساخت embeder و ChromaDB ================
# کرومای محلی با persist
client = chromadb.Client(Settings(chroma_db_impl="duckdb+parquet", persist_directory=CHROMA_PERSIST_DIR))
# تابع embedding: از sentence-transformers استفاده میکنیم
embedder = SentenceTransformer(EMBEDDING_MODEL_NAME)
def embed_texts(texts):
# بازگرداندن لیست بردارها (float list)
embs = embedder.encode(texts, convert_to_numpy=True, show_progress_bar=False)
# نرمالایز بردارها برای مقایسه کسینوسی
norms = np.linalg.norm(embs, axis=1, keepdims=True)
norms[norms == 0] = 1
embs = embs / norms
return embs.tolist()
# ایجاد کالکشن در ChromaDB اگر وجود نداشت
collection_name = "persian_religious_kb"
if collection_name in [c.name for c in client.list_collections()]:
collection = client.get_collection(name=collection_name)
else:
collection = client.create_collection(name=collection_name)
# اگر کالکشن خالی است، وارد کنیم؛ در غیر اینصورت از persist استفاده شود
existing_count = len(collection.get(include=["metadatas"])["metadatas"])
if existing_count == 0:
texts = [doc["text"] for doc in documents]
ids = [doc["id"] for doc in documents]
metadatas = [{"question": doc["question"], "source": doc["source"], "chunk_index": doc["chunk_index"]} for doc in documents]
embeddings = embed_texts(texts)
collection.add(ids=ids, documents=texts, metadatas=metadatas, embeddings=embeddings)
client.persist()
print("اندیس جدید در ChromaDB ساخته و ذخیره شد.")
else:
print("اندیس ChromaDB از قبل وجود دارد، از persist بارگذاری شد.")
# ================ تابع بازیابی با Chroma ================
def retrieve(query, top_k=TOP_K):
q_clean = preprocess_tokens(query)
q_emb = embed_texts([q_clean])[0]
results = collection.query(query_embeddings=[q_emb], n_results=top_k, include=["distances", "metadatas", "documents", "ids"])
# بازگرداندن لیستی از (text, metadata, score)
hits = []
for doc, meta, dist in zip(results["documents"][0], results["metadatas"][0], results["distances"][0]):
# Chroma بازگرداننده distance مشابه با 1-cosine (یا بسته به پیادهسازی)، ما تبدیل معکوس نمره را انجام میدهیم
# اگر distance برابر 0 باشد => شباهت کامل. برای خوانایی، ما یک score بین 0 و 1 میسازیم:
score = 1.0 - dist
hits.append({"text": doc, "meta": meta, "score": score})
return hits
# ================ تابع تولید پاسخ (با OpenAI به عنوان مثال) ================
def generate_answer_with_openai(user_question, retrieved_hits):
# اگر hit ای با score بالا وجود داشت و مشابهت بسیار زیاد بود، مستقیماً از متن بازیابیشده استفاده میکنیم
if retrieved_hits and retrieved_hits[0]["score"] > 0.90:
# بازگرداندن پاسخ دقیق از چانک اول همراه با منبع
txt = retrieved_hits[0]["text"]
src = retrieved_hits[0]["meta"].get("source", "نامشخص")
return f"{txt}\n\n(منبع: {src})", retrieved_hits[0]["score"]
# ساختن prompt برای LLM
context_texts = "\n\n---\n\n".join([f"[منبع: {h['meta'].get('source','-')}] {h['text']}" for h in retrieved_hits])
prompt = f"""
شما یک دستیار پاسخدهنده به سوالات شرعی هستید. با احترام و دقت پاسخ بده و هر جا محتوای بازیابیشده هست، به آن ارجاع بده.
متن سوال کاربر: {user_question}
متنهای مرتبط بازیابیشده:
{context_texts}
دستورالعملها:
1) ابتدا پاسخ کوتاه و مستقیم بده.
2) سپس توضیح تفصیلی با استدلالهای شرعی ارائه کن.
3) در پایان، منابع بازیابیشده را لیست کن.
"""
# تماس به OpenAI ChatCompletion (نمونه)
response = openai.ChatCompletion.create(
model="gpt-4o-mini", # مدل دلخواه؛ اگر ندارید از مدل دیگر استفاده کنید
messages=[
{"role": "system", "content": "شما یک دستیار دقیق و مستند هستید."},
{"role": "user", "content": prompt}
],
max_tokens=700,
temperature=0.0
)
answer_text = response["choices"][0]["message"]["content"].strip()
# میانگین نمره ساده (میتوان بهتر وزندهی کرد)
avg_score = np.mean([h["score"] for h in retrieved_hits]) if retrieved_hits else 0
return answer_text, avg_score
# ================ حلقه تعاملی ربات ================
def interactive_loop():
print("ربات RAG آماده است. برای خروج 'خروج' را وارد کنید.")
while True:
user_input = input("\nسؤال: ").strip()
if user_input in ["خروج", "exit", "quit"]:
print("خداحافظ")
break
if not user_input:
print("لطفاً یک سؤال وارد کنید.")
continue
hits = retrieve(user_input, top_k=TOP_K)
# فیلتر کردن نتایج ضعیف
hits = [h for h in hits if h["score"] >= SCORE_THRESHOLD]
if not hits:
print("متأسفانه من نتوانستم پاسخ مرتبطی پیدا کنم. لطفاً سؤال را دقیقتر بپرسید.")
continue
answer, score = generate_answer_with_openai(user_input, hits)
print("\nپاسخ:\n", answer)
print(f"\n(میانگین شباهت بازیابی: {score:.3f})")
if __name__ == "__main__":
interactive_loop()
توضیح گامبهگام کد
-
پاکسازی و نرمالسازی: توابع
clean_textوpreprocess_tokensمتن ورودی را برای فارسی استاندارد میکنند. اگر متن حاوی علائم انگلیسی یا کاراکترهای اضافی باشد، حذف یا فیلتر میشوند. توجه: اگر حروف خاص یا نام متون شرعی دارید، آنها را از فرآیند حذف استثنا کنید. -
چانکسازی: تابع
chunk_textپاسخها را به چانکهایی حداقلی تقسیم میکند تا embedding معنادار تولید شود. بهتر است مرزهای جمله حفظ شوند تا معنا از بین نرود. -
ایجاد Embeddings: با استفاده از
SentenceTransformerبردارها تولید و نرمالایز میشوند. نرمالایز کردن بردارها برای مقایسه کسینوسی مهم است. -
ذخیره در ChromaDB: اگر اندیس موجود نبود، دادهها وارد میشوند و persist انجام میشود. در دفعات بعدی از persist بارگذاری میکنیم تا از محاسبات تکراری جلوگیری شود.
-
بازیابی: با تابع
retrieveبردار query محاسبه و نزدیکترین اسناد بازیابی میشوند؛ ما distance را به score تبدیل میکنیم. -
سازماندهی پاسخدهی: ابتدا بررسی میکنیم آیا نتیجه بسیار دقیقی داریم (score>0.90)؛ در این صورت از آن پاسخ مستقیم استفاده میکنیم تا احتمال اشتباه مدل کاهش یابد. در غیر این صورت متنهای بازیابیشده در prompt قرار میگیرند و به LLM (اینجا OpenAI) داده میشود تا پاسخی مستند تولید کند.
-
تعامل: حلقه تعاملی به کاربر اجازه میدهد سؤال بپرسد و پاسخ دریافت کند.
آزمایش و اعتبارسنجی: چگونه کیفیت را بسنجیم؟
برای سنجش کیفیت از معیارهای زیر استفاده کنید:
-
Precision@k: آیا اولین پاسخها مرتبط و درست هستند؟
-
Mean Reciprocal Rank (MRR): موقعیت اولین نتیجه مرتبط.
-
BLEU / ROUGE بین پاسخ تولیدی و پاسخ مرجع (در مواردی که پاسخ مرجع وجود دارد).
-
ارزیابی انسانی: خصوصاً برای موضوعات شرعی، یک ارزیابی انسانی توسط کارشناسان واجب است.
بهینهسازی: افزایش دقت بازیابی و جلوگیری از هالوسینیشن
-
از مدل embedding بهتر یا fine-tune استفاده کنید.
-
از فیلترهای متادیتا استفاده کنید (مثلاً ملاک زمان، مرجع یا درجه اعتبار منبع).
-
برای تولید از temperature پایین (مثلاً 0.0) استفاده کنید تا مدل کمتر حدس بزند.
-
اگر پاسخ حساس یا قانونی/شرعی است، مکانیزم fallback داشته باشید که پاسخ را به انسان کارشناس منتقل کند.
نکات عملی و مسائل اخلاقی / شرعی در ساخت رباتهای پاسخگو مذهبی
-
مسئولیت: ارائه یک پاسخ شرعی خودکار مسئولیتزا است؛ باید مشخص کنید که پاسخهای ربات جایگزین مرجع شرعی معتبر نیستند.
-
ارجاع منبع: هر پاسخ باید منابع بازیابیشده را نمایش دهد.
-
نسخهی انسانی: برای سوالات بحرانی یک مسیر ارجاع به کارشناس واقعی تعبیه کنید.
-
حساسیت دادهها: دادههای مذهبی اغلب حساساند؛ مجوز استفاده از منابع را بررسی کنید.