تصور کنید یک برنامهنویس با درماندگی به تیکت پشتیبانی مینویسد: «دستیار هوش مصنوعی میگوید مستندات ما دربارهٔ استرداد وجه چیزی نمیگوید، در حالی که ما یک صفحه کامل با همین عنوان داریم.» این یک خط ساده، پرده از یک شکست خاموش در یک سیستم تولید بازیابیافزا (RAG) در مقیاس تولید برداشت؛ جایی که بات با اطمینان ادعا میکرد دادهای وجود ندارد، در حالی که محتوا بهدرستی تکهبندی، تبدیل به بردار شده و با شناسهٔ درست در پایگاهداده ذخیره شده بود. محتوا آنجا بود، در Postgres نشسته بود و tenant_id صحیحی داشت.
مقصر اصلی، مکانیزم فیلترینگ HNSW در pgvector است؛ ابزاری که میتواند باعث شود یک پرسوجو برای ۱۰ ردیف، بدون هیچ خطا یا تایمآوتی، صفر نتیجه برگرداند. اکثر توسعهدهندگان با پایگاهدادههای برداری مثل جعبههای سیاهی برخورد میکنند که صرفاً «بهترین تطبیق» را پیدا میکنند. اما در واقعیت، ایندکسهای جستوجوی نزدیکترین همسایه تقریبی (ANN) — شبیه به نقشهای که برای سرعت بیشتر، برخی از مسیرهای فرعی را نادیده میگیرد — سرعت را بر بازیابی کامل (Perfect Recall) ترجیح میدهند. انتخاب ابزار مناسب برای این لایه حیاتی است، همانطور که در مقایسه قابلیتهای جستوجوی برداری در MariaDB و PostgreSQL بررسی کردیم تا هر کدام برای سناریوهای متفاوتی بهینه باشند.
وقتی شما یک عبارت WHERE به جستوجوی برداری اضافه میکنید، در واقع به دنبال نزدیکترین همسایگانی نمیگردید که با معیارهای شما مطابقت داشته باشند؛ بلکه ابتدا نزدیکترین همسایگان را در کل فضای داده پیدا میکنید و سپس آنهایی را که با شرط شما نمیخوانند، دور میریزید. همانطور که در تحلیلهای پیشین ما دربارهی بهینهسازی بازیابی در مدلهای زبانی اشاره کردیم، دقت در لایهی بازیابی، تعیینکنندهٔ کیفیت پاسخ نهایی است.
این ویژگی معماری، یک «تلهٔ گزینش» (Selectivity Trap) برای برنامههای چندمستاجری (Multi-tenant) ایجاد میکند. اگر دادههای شما بین مشتریان مختلف تقسیم شده باشد، دادههای یک مشتری کوچک ممکن است از نظر ریاضی از همسایگان نزدیکترینِ جهانی فاصله داشته باشند. چون ایندکس از فیلتر tenant_id شما بیخبر است، لیست کاندیداهای خود را با دادههای مشتریان بزرگ پر میکند و برای مشتری کوچک چیزی باقی نمیگذارد.
مکانیسم شکست
طبق مستندات فنی، این شکست از پارامتر hnsw.ef_search نشأت میگیرد که بهصورت پیشفرض روی ۴۰ تنظیم شده است. ایندکس ابتدا ۴۰ بردار نزدیک را در کل جدول پیدا میکند و سپس Postgres فیلتر WHERE را روی این ۴۰ مورد اعمال میکند.
یک پرسوجوی معمولی در RAG را در نظر بگیرید:SELECT id, content FROM chunks WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10;
در این حالت، اگر ایندکس از طریق دستور CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); ایجاد شده باشد، ایندکس هیچ اطلاعی از tenant_id ندارد. ایندکس روی گراف بردارها حرکت میکند، با حرکتی حریصانه (Greedy) به سمت پرسوجو میرود و لیستی از بهترین کاندیداهایی که تا به حال دیده را نگه میدارد. پس از پایان حرکت، ۴۰ ردیف را به Postgres تحویل میدهد. سپس اجراکننده فیلتر WHERE tenant_id = 42 را اعمال میکند. اگر هیچکدام از آن ۴۰ مورد متعلق به مستاجر ۴۲ نباشند، شما هیچ نتیجهای دریافت نمیکنید.
به گزارش یکی از موارد واقعی، در جدولی با ۲۰۰ هزار تکه، یک مستاجر بزرگ مالک اکثر دادهها بود و مستاجر ۴۲ تنها ۴ هزار تکه (۲٪) داشت. بررسی EXPLAIN ANALYZE روی این پرسوجوی خراب نشان داد: Limit (actual rows=0) -> Index Scan using chunks_embedding_idx on chunks (actual rows=0) Filter: (tenant_id = 42) Rows Removed by Filter: 40. عبارت «Rows Removed by Filter: 40» تمام داستان را روایت میکند.
اگر مستاجری تنها ۲٪ از ردیفها را داشته باشد، ریاضیات بیرحمانه است. تعداد مورد انتظار تطبیقها برابر است با ۴۰ کاندیدا ضرب در ۰.۰۲ گزینش، که منجر به بهطور متوسط ۰.۸ ردیف میشود. احتمال بازگشت صفر نتیجه در این حالت $0.98^{40} \approx 45%$ است.
شباهت معنایی (Semantic Similarity) — که مثل یک آهنربای قدرتمند، کلمات مشابه را در فضای برداری کنار هم جمع میکند — میتواند وضعیت را بدتر کند. اگر یک مستاجر بزرگ و یک مستاجر کوچک هر دو سندی درباره «سیاست استرداد» داشته باشند، همسایگان نزدیکترینِ جهانی احتمالاً توسط تکههای مستاجر بزرگ اشغال میشوند. ایندکس بهطور موثری دادههای مرتبط مستاجر کوچکتر را بیرون میراند، زیرا تکههای مشابه دقیقاً در کنار یکدیگر در فضای جاسازی (Embedding Space) قرار دارند.
آستانهٔ گزینش
شکست HNSW زمانی آغاز میشود که فیلتر شما کمتر از LIMIT / ef_search از کل جدول را شامل شود. برای LIMIT 10 و ef_search = 40 پیشفرض، این آستانه ۲۵٪ است:
- گزینش ۵۰٪: ۲۰ ردیف مورد انتظار از ۴۰ کاندیدا؛ شما ۱۰ مورد را دریافت میکنید.
- گزینش ۲۵٪: ۱۰ ردیف مورد انتظار؛ شما حدود ۱۰ مورد (گاهی کمتر) میگیرید.
- گزینش ۱۰٪: ۴ ردیف مورد انتظار؛ شما حدود ۴ مورد میگیرید.
- گزینش ۲٪: ۰.۸ ردیف مورد انتظار؛ شما ۰ یا ۱ نتیجه میگیرید.
README پروژه pgvector تأیید میکند که شرطی که ۱۰٪ ردیفها را شامل شود، با تنظیمات پیشفرض بهطور متوسط ۴ ردیف برمیگرداند.
شناسایی باگ خاموش
این باگ بهشدت دشوار است چون پرسوجو از نظر فنی موفق است. هیچ هشدار کندی یا کرشی رخ نمیدهد. مدل زبانی بزرگ (LLM) — مثل کتابخانهداری که میلیاردها صفحه را خوانده و حالا با همان لحن کتابها جواب میدهد — صرفاً یک بستر خالی دریافت میکند و مودبانه میگوید اطلاعات موجود نیست.
برای شناسایی این مشکل، توسعهدهندگان باید هر بازیابی (Retrieval) که تعداد ردیفهای کمتری از LIMIT درخواستی برمیگرداند را ثبت کنند. این کار را میتوان با چند خط کد پیاده کرد:
rows = cur.execute(RETRIEVAL_SQL, (tenant_id, query_vec)).fetchall()
if len(rows) < k:
log.warning("retrieval_shortfall", extra={"tenant_id": tenant_id, "wanted": k, "got": len(rows)})
اگر مستاجر واقعاً کمتر از k تکه داده داشته باشد، نتیجه کوتاه طبیعی است. اما اگر مستاجر هزاران تکه داده دارد و نتیجه کوتاه است، این یک سیگنال باگ است. همچنین اجرای هفتگی یک بررسی Recall — مقایسه مجموعههای ID یک پرسوجوی ایندکسشده در مقابل یک اسکن دقیق CTE — میتواند آشکار کند که آیا همپوشانی پرسوجوهای فیلترشده در حال افت است یا خیر.
سه مسیر برای حل مشکل
بسته به مقیاس دادهها، سه راهکار وجود دارد:
- اسکنهای تکرارشونده (Iterative Index Scans): در نسخه ۰.۸.۰ به بعد pgvector، تنظیم
hnsw.iterative_scanرویstrict_orderیاrelaxed_orderبه ایندکس اجازه میدهد تا زمان رسیدن بهLIMITیا رسیدن به سقف ایمنی (hnsw.max_scan_tuplesکه پیشفرض ۲۰ هزار ردیف است)، به حرکت در گراف ادامه دهد. به جای «۴۰ مورد را پیدا کن، فیلتر کن و تمام»، فرآیند تبدیل میشود به «۴۰ مورد را پیدا کن، فیلتر کن، هنوز کم است؟ ادامه بده». - ایندکسهای جزئی یا پارتیشنبندی:
- ایندکس جزئی: برای چند دسته بزرگ، از
CREATE INDEX ... WHERE (tenant_id = 42);استفاده کنید. در این حالت هر کاندیدا از پیش متعلق به مستاجر درست است. - پارتیشنبندی: برای مقادیر زیاد، از
PARTITION BY LIST (tenant_id)استفاده کنید. Postgres دادهها را به پارتیشن درست هدایت کرده و فقط در آن گراف جستوجو میکند.
- ایندکس جزئی: برای چند دسته بزرگ، از
- جستوجوی دقیق برای مجموعههای کوچک: برای مستاجرانی با تعداد ردیف کم، با استفاده از CTE، اسکن دقیق را اجبار کنید تا ایندکس HNSW نتواند برای مرتبسازی استفاده شود:
WITH tenant_chunks AS MATERIALIZED (
SELECT id, content, embedding FROM chunks WHERE tenant_id = 42
)
SELECT id, content FROM tenant_chunks ORDER BY embedding <=> $1 LIMIT 10;
استراتژی پیادهسازی
افزایش hnsw.ef_search (مثلاً به ۴۰۰) تنها یک مسکن است؛ چون هزینه جستوجو را برای هر پرسوجو بالا میبرد و در گزینشهای بسیار پایین (مثلاً ۰.۱٪) باز هم شکست میخورد.
بهترین رویکرد، یک مدل ترکیبی است. مستاجران با حجم داده بالا برای سرعت روی ایندکس HNSW بمانند و مستاجران کوچک به مسیر جستوجوی دقیق هدایت شوند تا بازیابی ۱۰۰٪ تضمین شود.
فعال کردن اسکنهای تکرارشونده در تراکنش بازیابی — با استفاده از دستور SET LOCAL hnsw.iterative_scan = relaxed_order — از تغییرات پیشبینینشده در عملکرد کل پایگاهداده جلوگیری میکند. اگر از relaxed_order استفاده کنید، نتایج ممکن است کمی خارج از ترتیب باشند و باید با استفاده از یک CTE متریالیزه شده دوباره مرتب شوند. این کار تضمین میکند که «پرتگاه» (Cliff) جایی که نتایج ناپدید میشوند، به شدت به عقب رانده شود یا کاملاً حذف گردد.
این تغییر در رویکرد، RAG را از «بازیابی امیدوارانه» به «بازیابی تضمینشده» تبدیل میکند و توسعهدهندگان را مجبور میکند تا به جای تکیه بر جادوی ایندکسهای برداری، از حفاظهای (Guardrails) صریح استفاده کنند.
گام بعدی شما
- بررسی کنید آیا در سیستمهای چندمستاجری خود، نرخ نتایج بازگشتی برای مشتریان کوچکتر از مشتریان بزرگ است یا خیر.
- در صورت استفاده از pgvector نسخه ۰.۸.۰ به بالا، پارامتر
hnsw.iterative_scanرا برای محیطهای حساس فعال کنید. - برای مستاجرانی که حجم دادههایشان زیر یک حد مشخص (مثلاً ۱۰۰۰ ردیف) است، مسیر جستوجوی دقیق (Exact Search) را جایگزین HNSW کنید.
اما داستان سختافزاری این تحول حتی شگفتانگیزتر است — به تحلیل ما دربارهی تراشههای Blackwell مراجعه کنید.




گفتگو