# منطق سرچ و فیلترها
این سند دقیقاً توضیح می‌دهد یک کوئری چطور به نتیجه تبدیل می‌شود: چه چیزی امتیاز می‌گیرد، چه چیزی فقط حذف می‌کند، و چرا.
--- ## ۱. آناتومی یک درخواست
| بخش | نقش | امتیاز می‌دهد؟ | |---|---|---| | `q` | تعیین می‌کند چه کسی **مرتبط** است | ✅ | | فیلترها | تعیین می‌کنند چه کسی **واجد شرایط** است | ❌ | | `sort` | ترتیب نهایی | ❌ | | `page` / `page_size` | برش | ❌ | این تفکیک در Elasticsearch به‌صورت مستقیم به `must`/`should` (بخش امتیازدهی) در برابر `filter` (بخش حذف) نگاشت می‌شود. مزیت عملی: کلاز‌های `filter` توسط ES کش می‌شوند و هزینه محاسبه امتیاز ندارند.
--- ## ۲. ایندکس Elasticsearch ### ۲.۱ آنالایزرها ```jsonc "english_text": { // برای همه فیلدهای متنی "tokenizer": "standard", "filter": ["lowercase", "asciifolding", "english_possessive", "english_stop", "english_stemmer"] } "autocomplete_index": { "filter": [..., "edge_ngram(2,15)"] } // زمان ایندکس "autocomplete_search": { "filter": ["lowercase", "asciifolding"] } // زمان کوئری ```
سه نکته: 1. **`asciifolding`** باعث می‌شود «José» با «jose» پیدا شود. 2. **`english_stemmer`** «engineering»، «engineer» و «engineers» را یکی می‌کند. 3. **آنالایزر متفاوت برای ایندکس و سرچ در autocomplete** حیاتی است: اگر عبارت کاربر هم n-gram شود، «jo» با هر چیزی که حرف «j» دارد مچ می‌شود. با `search_analyzer` جدا، فقط پیشوندها مچ می‌شوند. ### ۲.۲ چرا هر فیلد دو نسخه دارد
```jsonc "skills": { "type": "text", "analyzer": "english_text", // برای سرچ متنی "fields": { "keyword": { "type": "keyword", "normalizer": "lowercase_normalizer" }, // برای فیلتر و facet "autocomplete": { ... } // برای type-ahead } } ```
متن آنالایزشده برای فیلتر دقیق و شمارش aggregation قابل استفاده نیست — «project management» به دو توکن شکسته می‌شود و در facet دو ردیف جدا می‌سازد. زیرفیلد `keyword` مقدار خام (فقط lowercase و ascii-folded) را نگه می‌دارد. ### ۲.۳ `dynamic: strict` mapping عمداً strict است. اگر داکیومنتی فیلدی داشته باشد که در mapping نیست، Elasticsearch کل داکیومنت را رد می‌کند — به‌جای اینکه بی‌صدا نوعش را حدس بزند و بعداً یک facet را خراب کند. یک تست (`test_document_only_contains_mapped_fields`) این قرارداد را قفل کرده است. ### ۲.۴ چه چیزی ایندکس **نمی‌شود** ایمیل، شماره تلفن و آدرس خیابان **هرگز** وارد ایندکس نمی‌شوند. دلیل صرفاً «حریم خصوصی به‌طور کلی» نیست: اگر ایمیل ایندکس شود، هر کاربری می‌تواند با یک سرچ حدسی وجود یک آدرس ایمیل را تأیید کند (oracle attack). این داده‌ها فقط در Postgres می‌مانند و در لایه سریالایزر ماسک می‌شوند.
--- ## ۳. کوئری کلیدواژه ```python # apps/search/backends/elastic.py SEARCH_FIELDS = [ "full_name^6", # نام مهم‌ترین سیگنال است "job_title^4", "skills^3.5", "company^2.5", "headline^2", "past_companies^1.5", "industry^1.5", "majors^1.5", "education", "location_name", "summary", # پرحجم‌ترین و کم‌سیگنال‌ترین "interests", ] PHRASE_FIELDS = ["full_name^8", "job_title^6", "skills^5", "company^4", "headline^3"] ``` ### ۳.۱ چهار پارامتر کلیدی | پارامتر | مقدار | چرا | |---|---|---| | `type` | `best_fields` | یک پروفایل که «python» را قوی در `skills` دارد، بهتر از پروفایلی است که آن را ضعیف در چند فیلد پخش کرده | | `minimum_should_match` | `2<70%` | کوئری یک‌کلمه‌ای: مچ اجباری. کوئری سه‌کلمه‌ای: ۷۰٪ کافی است، پس «senior python django» کسی را که فقط دوتایش را دارد هم برمی‌گرداند | | `fuzziness` | `AUTO` | فاصله ویرایشی متناسب با طول کلمه؛ «pyhton» → «python» | | `prefix_length` | `1` | حرف اول باید دقیق باشد؛ جلوی انفجار کاندیدها و نتایج بی‌ربط را می‌گیرد | ### ۳.۲ بوست عبارت
کلاز `should` (با `boost: 4`) هیچ‌وقت نتیجه‌ای را حذف نمی‌کند، فقط امتیاز اضافه می‌کند. اثرش: | کوئری | بدون بوست عبارت | با بوست عبارت | |---|---|---| | `product manager` | کسی که «product» در عنوان و «manager» در خلاصه دارد ممکن است اول بیاید | کسی که عنوانش دقیقاً «product manager» است اول می‌آید | `slop: 1` اجازه می‌دهد یک کلمه فاصله بیفتد، پس «product marketing manager» هم بوست می‌گیرد ولی کمتر.
--- ## ۴. فیلترها ### ۴.۱ نگاشت پارامتر به فیلد ایندکس ```python FILTER_FIELDS = { "skills": "skills.keyword", "roles": "job_title_role", "seniority": "seniority_levels", "countries": "location_country", "industries": "industry.keyword", "companies": "company.keyword", } ```
هر کدام به یک کلاز `terms` تبدیل می‌شوند و بازه تجربه به یک `range`. ### ۴.۲ قاعده OR/AND
``` ?skills=python,django&countries=germany&min_experience=5 ⇒ filter: [ { terms: { "skills.keyword": ["python", "django"] } }, ← OR داخلی { terms: { "location_country": ["germany"] } }, ← AND با قبلی { range: { "years_experience": { "gte": 5 } } } ← AND با قبلی ] ```
**چرا این قاعده؟** چون با مدل ذهنی کاربر از یک پنل فیلتر می‌خواند: - تیک زدن مهارت دومِ **همان دسته** = «یا این یا آن» ⇒ نتایج بیشتر می‌شوند. - تیک زدن یک کشور در دسته **دیگر** = «و همچنین» ⇒ نتایج کمتر می‌شوند. اگر مهارت‌ها هم AND می‌شدند، کاربر با تیک زدن مهارت دوم معمولاً به صفر نتیجه می‌رسید و فکر می‌کرد سیستم خراب است. ### ۴.۳ هر دو نگارش کوئری‌استرینگ پشتیبانی می‌شوند
``` ?skills=python,django # جدا با کاما ?skills=python&skills=django # پارامتر تکراری ```
`CommaSeparatedField.get_value()` در حالت دوم از `QueryDict.getlist` استفاده می‌کند (متد `get` فقط آخرین مقدار را برمی‌گرداند — یک باگ کلاسیک). هر دو نگارش نرمال، lowercase، یکتا و به حداکثر ۲۰ مقدار محدود می‌شوند.
--- ## ۵. Facetها ```jsonc "aggs": { "skills": { "terms": { "field": "skills.keyword", "size": 25 } }, "job_title_role": { "terms": { "field": "job_title_role", "size": 25 } }, "seniority": { "terms": { "field": "seniority_levels","size": 25 } }, "country": { "terms": { "field": "location_country","size": 25 } }, "industry": { "terms": { "field": "industry.keyword","size": 25 } }, "company": { "terms": { "field": "company.keyword", "size": 25 } } } ```
facetها روی **مجموعه نتایج فیلترشده فعلی** محاسبه می‌شوند، نه روی کل دیتاست. یعنی وقتی کاربر «engineering» را انتخاب کرده، شمارش کشورها فقط مهندس‌ها را می‌شمارد. دو جزئیات UX که در فرانت‌اند رعایت شده‌اند: 1. **مقدار انتخاب‌شده هیچ‌وقت ناپدید نمی‌شود** — حتی اگر شمارشش صفر شود، وگرنه کاربر نمی‌تواند تیکش را بردارد. 2. **رشته خالی از facet حذف می‌شود** — دیتاست پر از فیلدهای خالی است و یک باکت بی‌نام فقط سردرگمی می‌سازد. `facets=false` را می‌شود فرستاد تا aggregation اجرا نشود (برای صفحات بعدی که پنل فیلتر تغییری نکرده).
--- ## ۶. مرتب‌سازی | `sort` | Elasticsearch | PostgreSQL | |---|---|---| | `relevance` (پیش‌فرض) | `["_score", {"id": "asc"}]` | `-rank, full_name, id` | | `-experience` | `years_experience desc, missing _last` | `years_experience desc nulls last` | | `experience` | `years_experience asc, missing _last` | `years_experience asc nulls last` | | `name` | `full_name.keyword asc` | `full_name asc` |
**نکته `missing: _last`:** ۶۴ پروفایل از ۲۸۴ سال تجربه ندارند. بدون این تنظیم، مرتب‌سازی صعودی همه‌شان را اول می‌گذارد و صفحه اول پر از داده ناقص می‌شود. **نکته tie-break:** همه‌جا `id` به‌عنوان معیار آخر اضافه شده تا صفحه‌بندی پایدار باشد؛ بدون آن، دو رکورد با امتیاز برابر می‌توانند بین صفحه ۱ و ۲ جابه‌جا شوند و یکی دوبار دیده و دیگری اصلاً دیده نشود.
--- ## ۷. Highlight ```jsonc "highlight": { "pre_tags": [""], "post_tags": [""], "fields": { "full_name": { "number_of_fragments": 0 }, // کل مقدار "job_title": { "number_of_fragments": 0 }, "skills": { "number_of_fragments": 3 }, "summary": { "fragment_size": 160, "number_of_fragments": 1 } } } ```
`number_of_fragments: 0` یعنی «کل فیلد را برگردان، نه یک تکه» — برای نام و عنوان که کوتاه‌اند درست است. برای `summary` یک قطعه ۱۶۰ کاراکتری کافی است. **نکته امنیتی:** snippet برگشتی شامل تگ HTML است ولی محتوایش داده کاربر است. فرانت‌اند آن را با `dangerouslySetInnerHTML` رندر **نمی‌کند**؛ کامپوننت `Highlight` رشته را روی تگ‌های `` می‌شکند و بقیه را به‌عنوان text node رندر می‌کند. نتیجه یکسان، بدون سطح حمله XSS.
--- ## ۸. بک‌اند PostgreSQL ### ۸.۱ tsvector وزن‌دار ```python Profile.objects.update(search_vector=( SearchVector("full_name", weight="A", config="english") + SearchVector("job_title", weight="B", config="english") + SearchVector("skills_text", weight="B", config="english") + SearchVector("companies_text", weight="B", config="english") + SearchVector("industry", weight="C", config="english") + SearchVector("education_text", weight="C", config="english") + SearchVector("location_name", weight="C", config="english") + SearchVector("summary", weight="D", config="english") )) ```
وزن‌های پیش‌فرض PostgreSQL: A=1.0، B=0.4، C=0.2، D=0.1 — همان سلسله‌مراتبی که در ES با بوست فیلدها ساخته شد. ### ۸.۲ تحمل غلط تایپی
```python .annotate( text_rank = SearchRank(F("search_vector"), query), name_similarity= TrigramSimilarity("full_name", term), ) .annotate(rank=Greatest(F("text_rank"), )) .filter(Q(search_vector=query) | Q(name_similarity__gte=0.3)) ```
`Greatest` یعنی یک تطابق قوی نام به‌اندازه یک تطابق قوی متنی ارزش دارد. آستانه ۰٫۳ تجربی است: پایین‌تر نویز می‌آورد، بالاتر «jhon» را رد می‌کند. ### ۸.۳ نگارش کوئری `search_type="websearch"` استفاده شده، پس کاربر همان نحوی را دارد که از موتورهای جستجو می‌شناسد:
``` python django دو کلمه، هر دو لازم (AND) "product manager" عبارت دقیق python or ruby یکی از دو python -junior شامل python، بدون junior ``` ### ۸.۴ تفاوت‌های آگاهانه با Elasticsearch
| موضوع | Elasticsearch | PostgreSQL | |---|---|---| | چند کلمه | ۷۰٪ کافی است (بازیابی بالاتر) | همه لازم‌اند (دقت بالاتر) | | غلط تایپی | روی همه فیلدها | فقط روی نام | | highlight | highlighter بومی | جایگزینی توکن سمت پایتون | | facet | aggregation | شمارش در پایتون روی مجموعه فیلترشده | | مقیاس | میلیون‌ها رکورد | تا حدود ده‌ها هزار رکورد راحت | این تفاوت‌ها پنهان نشده‌اند: پاسخ API فیلد `backend` دارد و UI هم موتور فعال را نشان می‌دهد. برای دیتاستی با ۲۸۴ رکورد، تفاوت عملاً محسوس نیست — که دقیقاً چیزی است که یک fallback خوب را ممکن می‌کند.
--- ## ۹. محافظ‌ها
| محدودیت | مقدار | چرا | |---|---|---| | طول `q` | ۲۰۰ کاراکتر | کوئری غول‌پیکر = مصرف CPU | | تعداد مقدار در هر فیلتر | ۲۰ | جلوگیری از `terms` با هزاران مقدار | | `page_size` | حداکثر ۱۰۰ | جلوگیری از پاسخ چندمگابایتی | | `page × page_size` | حداکثر ۱۰٬۰۰۰ | صفحه‌بندی عمیق برای انسان بی‌فایده و برای موتور گران است — رد می‌شود، نه اینکه بی‌صدا کوتاه شود | | rate limit سرچ | ۱۲۰/دقیقه | سرچ گران‌ترین endpoint است | همه در `SearchRequestSerializer` اعمال می‌شوند، یعنی **قبل** از اینکه چیزی به موتور برسد.
--- ## ۱۰. نمونه‌های عملی ```bash # ساده‌ترین حالت: همه، مرتب بر اساس نام GET /api/v1/search/?sort=name # کلیدواژه GET /api/v1/search/?q=recruiting # کلیدواژه + دو فیلتر (خواسته حداقلی صورت تسک) GET /api/v1/search/?q=leadership&skills=management&roles=human_resources # چند مقدار در یک فیلتر → OR GET /api/v1/search/?skills=python,django,react # بازه تجربه + مرتب‌سازی GET /api/v1/search/?min_experience=10&max_experience=20&sort=-experience # صفحه دوم، بدون محاسبه دوباره facetها GET /api/v1/search/?q=engineer&page=2&facets=false # تحمل غلط تایپی GET /api/v1/search/?q=jhon # john را پیدا می‌کند # type-ahead GET /api/v1/search/suggest/?q=mar&limit=5 ```