امبدینگ فارسی و جستجوی معنایی؛ راهنمای عملی با pgvector
کاربر در جستجوی سایتتان مینویسد «گوشی ارزان» و هیچ نتیجهای نمیگیرد، چون در پایگاه داده اسم محصول «تلفن همراه اقتصادی» ثبت شده است. همکارتان دنبال «مرخصی استعلاجی» میگردد و سند مربوطه با عنوان «غیبت پزشکی» ذخیره شده است. جستجوی کلیدواژهای در هر دو مورد شکست میخورد و مشکل هم قابل حل با اضافهکردن چند مترادف دستی نیست. راهحل، جستجوی معنایی است: بهجای تطبیق حروف، شباهت معنا را حساب میکنیم. در این مقاله فقط روی همین مرحله تمرکز میکنیم؛ یعنی بازیابی، بدون هیچ مرحلهٔ تولید پاسخ.
چرا جستجوی کلیدواژهای روی فارسی زودتر کم میآورد؟
موتور جستجوی متنی سنتی، مثل چیزی که بهطور پیشفرض در پایگاه داده دارید، دنبال تطبیق رشته میگردد. در فارسی چهار چیز این تطبیق را خراب میکند:
- مترادفهای فراوان: گوشی، موبایل، تلفن همراه، هندست. هیچ اشتراک حرفی بین آنها نیست.
- نیمفاصله: «میرود»، «می رود» و «میرود» برای انسان یکیاند و برای موتور تطبیق رشته سه چیز متفاوت.
- حرفهای همشکل عربی و فارسی: «ي» و «ك» عربی در بسیاری از دادههای واقعی بهجای «ی» و «ک» نشستهاند.
- ترکیب فارسی و انگلیسی: کاربر «لپ تاپ» مینویسد و در پایگاه داده «Laptop» ثبت شده است.
میشود برای هر کدام یک وصله گذاشت، اما تجربه نشان داده این فهرست وصلهها هیچوقت تمام نمیشود. جستجوی معنایی بهجای وصلهزدن، مسئله را از ریشه عوض میکند.
امبدینگ چیست و بردار یعنی چه؟
مدل امبدینگ یک متن را میگیرد و یک فهرست بلند از عدد اعشاری برمیگرداند؛ مثلاً ۱٬۵۳۶ عدد. به این فهرست بردار میگویند. نکتهٔ جالب اینجاست که این اعداد تصادفی نیستند: مدل طوری آموزش دیده که متنهای هممعنا بردارهای نزدیک به هم بگیرند. بردار «گوشی هوشمند» به بردار «تلفن همراه» نزدیک است و از بردار «کفش ورزشی» دور.
پس کل ایده این است: هر سند را یک بار به بردار تبدیل و ذخیره کنید، پرسش کاربر را هم موقع جستجو به بردار تبدیل کنید، و بعد نزدیکترین بردارها را پیدا کنید. «نزدیکی» را هم با یک معیار ریاضی میسنجیم که در ادامه میبینیم.
یک سوءتفاهم رایج را رفع کنیم: امبدینگ معنا را «میفهمد» به این معنا نیست که استدلال میکند. اگر دو متن از نظر موضوع نزدیک باشند اما یکی نفی دیگری باشد، بردارهایشان همچنان نزدیک است. «ارسال رایگان است» و «ارسال رایگان نیست» شباهت بالایی میگیرند. این محدودیت واقعی است و باید در طراحی سیستم حواستان به آن باشد.
کدام مدل امبدینگ برای فارسی؟
معیار انتخاب سه چیز است: کیفیت روی زبانهای غیرانگلیسی، تعداد ابعاد بردار (که هم روی حجم ذخیرهسازی و هم روی سرعت جستجو اثر میگذارد) و هزینه. اعداد زیر نرخ رسمی سرویس و تقریبی هستند:
| مدل | ابعاد بردار | نرخ رسمی تقریبی هر یک میلیون توکن | مناسب چه کاری |
|---|---|---|---|
| text-embedding-3-small | ۱۵۳۶ (قابل کاهش) | حدود ۰٫۰۲ دلار | انتخاب پیشفرض؛ برای اکثر پروژههای فارسی کافی است |
| text-embedding-3-large | ۳۰۷۲ (قابل کاهش) | حدود ۰٫۱۳ دلار | وقتی دقت بازیابی روی متن تخصصی حیاتی است |
| text-embedding-ada-002 | ۱۵۳۶ | حدود ۰٫۱۰ دلار | نسل قدیمی؛ برای پروژهٔ جدید توصیه نمیشود |
| مدلهای چندزبانهٔ متنباز | معمولاً ۷۶۸ تا ۱۰۲۴ | بدون هزینهٔ سرویس، در ازای هزینهٔ سرور | وقتی داده حساس است و نباید از سرور خارج شود |
نکتهٔ عملی: مدلهای نسل جدید اجازه میدهند تعداد ابعاد را کاهش دهید. اگر میلیونها سند دارید، کمکردن ابعاد از ۱۵۳۶ به ۵۱۲ حجم ذخیرهسازی را یکسوم میکند و افت کیفیت معمولاً بسیار کمتر از چیزی است که انتظار دارید. مهمتر از انتخاب مدل این است که اسناد و پرسشها را با یک مدل یکسان امبد کنید؛ بردارهای دو مدل مختلف اصلاً قابل مقایسه نیستند و اگر روزی مدل را عوض کردید باید کل ایندکس را از نو بسازید.
متن فارسی را قبل از امبدینگ چطور آماده کنیم؟
قاعدهٔ کلی: فقط نویز را بردارید، نه معنا را. حروف عربی را به فارسی برگردانید، اعراب و کاراکترهای نامرئی جهتدهی را حذف کنید، فاصلههای اضافی را جمع کنید و نیمفاصله را یکدست کنید. اما ریشهیابی سنگین، حذف کلمات ایست و کوتاهکردن کلمات را انجام ندهید؛ این کارها میراث دوران جستجوی کلیدواژهایاند و روی مدلهای امروزی کیفیت را پایین میآورند.
مهمتر از نرمالسازی، این است که چه چیزی را امبد میکنید. برای یک کاتالوگ محصول، امبدکردن فقط عنوان نتیجهٔ ضعیفی میدهد؛ ترکیب عنوان بهعلاوهٔ دستهبندی و دو خط اول توضیحات معمولاً جهش محسوسی در کیفیت ایجاد میکند. برای اسناد بلند هم باید متن را به تکههای کوچک بشکنید؛ روش تکهبندی متن فارسی را در راهنمای ساخت چتبات RAG روی اسناد فارسی کامل توضیح دادهایم.
ذخیرهسازی بردارها در PostgreSQL با pgvector
برای چند هزار بردار، نگهداشتن آنها در حافظه هم جواب میدهد. اما بهمحض اینکه داده باید ماندگار باشد و همراه بقیهٔ رکوردها فیلتر شود، PostgreSQL با افزونهٔ pgvector سادهترین گزینهٔ پایدار است؛ روی یک سرور مجازی معمولی هم بهراحتی اجرا میشود و نیازی به سرویس تخصصی جداگانه ندارید.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
source text NOT NULL,
content text NOT NULL,
embedding vector(1536)
);
-- ایندکس تقریبی برای جستجوی سریع با فاصلهٔ کسینوسی
CREATE INDEX documents_embedding_idx
ON documents USING hnsw (embedding vector_cosine_ops);
عدد ۱۵۳۶ باید دقیقاً با ابعاد خروجی مدل شما یکی باشد. ایندکس hnsw یک ایندکس تقریبی است: جستجو را چند مرتبه سریعتر میکند و در عوض ممکن است گاهی یک نتیجهٔ حاشیهای را جا بیندازد. برای زیر ده هزار ردیف اصلاً به ایندکس نیاز ندارید و جستجوی کامل سریعتر و دقیقتر است.
جستجوی شباهت با فاصلهٔ کسینوسی
معیار متداول برای بردارهای متنی، شباهت کسینوسی است؛ یعنی زاویهٔ بین دو بردار، نه فاصلهٔ خطیشان. در pgvector عملگر <=> فاصلهٔ کسینوسی را میدهد که عددی بین صفر و دو است: صفر یعنی کاملاً همجهت. برای رسیدن به «شباهت» کافی است آن را از یک کم کنید.
import numpy as np
import psycopg
from pgvector.psycopg import register_vector
from openai import OpenAI
client = OpenAI(base_url="https://netarz.ir/api/ai/v1", api_key="sk-ntz-v1-...")
conn = psycopg.connect("postgresql://user:password@localhost/knowledge")
register_vector(conn)
def embed(texts):
response = client.embeddings.create(model="text-embedding-3-small", input=texts)
return [np.array(item.embedding, dtype=np.float32) for item in response.data]
def index_rows(rows, batch_size=64):
"""امبدینگ را دستهای بفرستید؛ هم سریعتر است هم درخواست کمتری مصرف میکند."""
for start in range(0, len(rows), batch_size):
batch = rows[start:start + batch_size]
vectors = embed([row["content"] for row in batch])
with conn.cursor() as cur:
for row, vector in zip(batch, vectors):
cur.execute(
"INSERT INTO documents (source, content, embedding) VALUES (%s, %s, %s)",
(row["source"], row["content"], vector),
)
conn.commit()
def search(question, k=5):
query = embed([question])[0]
with conn.cursor() as cur:
cur.execute(
"SELECT id, source, content, 1 - (embedding <=> %s) AS similarity "
"FROM documents ORDER BY embedding <=> %s LIMIT %s",
(query, query, k),
)
return cur.fetchall()
for row in search("هزینهٔ ارسال به شهرستان"):
print(round(row[3], 3), row[1])
همان جستجو در Node، برای وقتی که موتور جستجو را داخل بکاند سایت اجرا میکنید:
import pg from "pg";
import OpenAI from "openai";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const client = new OpenAI({
baseURL: "https://netarz.ir/api/ai/v1",
apiKey: process.env.NETARZ_AI_KEY,
});
export async function search(question, k = 5) {
const { data } = await client.embeddings.create({
model: "text-embedding-3-small",
input: question,
});
const vector = JSON.stringify(data[0].embedding);
const { rows } = await db.query(
`SELECT id, source, content, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[vector, k],
);
return rows;
}
یک ترفند که در عمل بیشترین تفاوت را میسازد: جستجوی معنایی و جستجوی کلیدواژهای را با هم ترکیب کنید. جستجوی معنایی مترادفها را میگیرد اما روی کد محصول، شمارهٔ قطعه و نام دقیق برند ضعیف است؛ جستجوی متنی دقیقاً برعکس. اجرای هر دو و ادغام نتایج، معمولاً بهترین خروجی را میدهد.
| روش | نقطهٔ قوت | نقطهٔ ضعف | هزینهٔ اجرا |
|---|---|---|---|
| کلیدواژهای | کد محصول، نام دقیق، عدد و شناسه | مترادف و نیمفاصله را نمیگیرد | تقریباً صفر |
| معنایی | مترادف، پرسش محاورهای، جملهٔ کامل | روی رشتههای دقیق و کدها ضعیف است | هزینهٔ امبدینگ پرسش |
| ترکیبی | پوشش هر دو حالت | پیادهسازی و تنظیم پیچیدهتر | مجموع دو روش |
از کجا بفهمیم بازیابی واقعاً خوب کار میکند؟
این بخشی است که تقریباً همه از آن رد میشوند و بعد ماهها روی حدس کار میکنند. سنجش کیفیت بازیابی سخت نیست: سی تا پنجاه پرسش واقعی کاربران را جمع کنید و برای هرکدام دستی مشخص کنید کدام سندها پاسخ درست را دارند. حالا دو عدد ساده حساب کنید:
- Recall@k: در چند درصد پرسشها، حداقل یک سند درست بین k نتیجهٔ اول بوده است. این مهمترین عدد شماست.
- MRR: میانگین معکوس رتبهٔ اولین نتیجهٔ درست. اگر نتیجهٔ درست معمولاً اول باشد به یک نزدیک میشود و اگر ته فهرست باشد به صفر.
GOLDEN = [
{"query": "هزینهٔ ارسال به شهرستان چقدر است؟", "relevant_ids": {12, 41}},
{"query": "گارانتی محصول چند ماه است؟", "relevant_ids": {77}},
]
def recall_at_k(k=5):
hits = 0
for case in GOLDEN:
found = {row[0] for row in search(case["query"], k)}
if found & case["relevant_ids"]:
hits += 1
return hits / len(GOLDEN)
def mrr(k=10):
total = 0.0
for case in GOLDEN:
for rank, row in enumerate(search(case["query"], k), start=1):
if row[0] in case["relevant_ids"]:
total += 1 / rank
break
return total / len(GOLDEN)
print("recall@5:", round(recall_at_k(5), 3))
print("MRR:", round(mrr(), 3))
حالا هر تغییری که میدهید — اندازهٔ تکه، مدل امبدینگ، اضافهکردن دستهبندی به متن امبدشده — این دو عدد را دوباره حساب کنید. اگر بالا رفتند تغییر خوب بوده، اگر نه برگردید. بدون این مجموعهٔ سنجش، بهبود سیستم بازیابی فقط سلیقه است.
یک آستانه هم برای امتیاز شباهت بگذارید. اگر بهترین نتیجه امتیاز پایینی داشت، بهتر است صادقانه بگویید چیزی پیدا نشد تا اینکه نامرتبطترین سند را به کاربر نشان دهید. عدد این آستانه به داده و مدل شما بستگی دارد و باید روی همان مجموعهٔ سنجش پیدا شود.
قدم بعدی
ترتیب پیشنهادی ما این است: اول یک نمونهٔ کوچک با هزار ردیف بسازید، بعد مجموعهٔ سنجش را درست کنید، و تنها بعد از آن سراغ کل داده بروید. تقریباً همیشه معلوم میشود مشکل در جای دیگری است؛ مثلاً متنی که امبد کردهاید اطلاعات لازم را ندارد. برآورد هزینهٔ امبدینگ کل داده هم ساده است: تعداد توکنها را حساب کنید و در نرخ مدل ضرب کنید. روش شمردن توکن را در راهنمای محاسبهٔ توکن و هزینه نوشتهایم و متن فارسی به ازای هر کلمه توکن بیشتری از انگلیسی مصرف میکند، پس حاشیهٔ اطمینان بگذارید.
وقتی بازیابی خوب شد، مرحلهٔ بعدی این است که نتایج را به یک مدل بدهید تا پاسخ بسازد؛ آن مرحله را جداگانه در راهنمای RAG فارسی ساختهایم. اگر هم وسط راه به خطای احراز هویت یا سهمیه خوردید، راهنمای خطاهای رایج API علت هرکدام را دقیق توضیح داده است.
برای اجرای کدهای بالا به یک کلید API و آدرس پایه نیاز دارید. مدلهای فعال و نرخ لحظهای هرکدام در صفحهٔ مدلهای نِت اَرز منتشر میشود و مستندات فنی در مستندات API است؛ هماکنون مدلهای OpenAI فعال است و بقیهٔ ارائهدهندگان در حال اتصالاند. اعتبار حساب را هم از صفحهٔ افزایش اعتبار وبسرویس ChatGPT با قیمت لحظهای همان صفحه میتوانید شارژ کنید و اگر تازه شروع کردهاید، راهنمای شارژ اعتبار OpenAI API مسیر را از صفر توضیح داده است.
پرسشهای پرتکرار
فرق جستجوی معنایی با جستجوی کلیدواژهای چیست؟
جستجوی کلیدواژهای دنبال تطبیق حروف میگردد و اگر کاربر مترادف بنویسد چیزی پیدا نمیکند. جستجوی معنایی متن را به بردار عددی تبدیل میکند و نزدیکی معنا را میسنجد، بنابراین «گوشی» و «تلفن همراه» را یکی میبیند. در عمل بهترین نتیجه از ترکیب هر دو روش به دست میآید.
برای فارسی کدام مدل امبدینگ را انتخاب کنم؟
مدلهای چندزبانهٔ نسل جدید مثل text-embedding-3-small برای اکثر پروژههای فارسی کافیاند و هزینهٔ بسیار پایینی دارند. اگر دقت روی متن تخصصی حیاتی است نسخهٔ large را امتحان کنید، اما پیش از هر چیز روی مجموعهٔ پرسشهای واقعی خودتان هر دو را بسنجید.
اگر مدل امبدینگ را عوض کنم باید همهچیز را دوباره بسازم؟
بله. بردارهای دو مدل مختلف در فضاهای متفاوتی قرار دارند و مقایسهشان بیمعناست. اگر مدل یا تعداد ابعاد را تغییر دادید، باید کل اسناد را دوباره امبد کنید و ایندکس را از نو بسازید؛ به همین دلیل بهتر است این تصمیم را زود و با آزمایش بگیرید.
برای شروع حتماً به پایگاه دادهٔ برداری تخصصی نیاز دارم؟
خیر. تا چند هزار سند، حتی نگهداشتن بردارها در حافظه هم کار میکند و تا چند صد هزار ردیف، PostgreSQL با افزونهٔ pgvector کاملاً کافی است. مزیت این مسیر آن است که بردارها کنار بقیهٔ دادهها میمانند و میتوانید همزمان فیلتر معمولی هم بزنید.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.