Files
cyberyan_linkedin_search/docs/SEARCH.md
T

371 lines
16 KiB
Markdown
Raw Normal View History

# منطق سرچ و فیلترها
<div dir="rtl">
این سند دقیقاً توضیح می‌دهد یک کوئری چطور به نتیجه تبدیل می‌شود: چه چیزی امتیاز می‌گیرد، چه چیزی فقط حذف می‌کند، و چرا.
</div>
---
## ۱. آناتومی یک درخواست
<div dir="rtl">
| بخش | نقش | امتیاز می‌دهد؟ |
|---|---|---|
| `q` | تعیین می‌کند چه کسی **مرتبط** است | ✅ |
| فیلترها | تعیین می‌کنند چه کسی **واجد شرایط** است | ❌ |
| `sort` | ترتیب نهایی | ❌ |
| `page` / `page_size` | برش | ❌ |
این تفکیک در Elasticsearch به‌صورت مستقیم به `must`/`should` (بخش امتیازدهی) در برابر `filter` (بخش حذف) نگاشت می‌شود. مزیت عملی: کلاز‌های `filter` توسط ES کش می‌شوند و هزینه محاسبه امتیاز ندارند.
</div>
---
## ۲. ایندکس 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"] } // زمان کوئری
```
<div dir="rtl">
سه نکته:
1. **`asciifolding`** باعث می‌شود «José» با «jose» پیدا شود.
2. **`english_stemmer`** «engineering»، «engineer» و «engineers» را یکی می‌کند.
3. **آنالایزر متفاوت برای ایندکس و سرچ در autocomplete** حیاتی است: اگر عبارت کاربر هم n-gram شود، «jo» با هر چیزی که حرف «j» دارد مچ می‌شود. با `search_analyzer` جدا، فقط پیشوندها مچ می‌شوند.
### ۲.۲ چرا هر فیلد دو نسخه دارد
</div>
```jsonc
"skills": {
"type": "text", "analyzer": "english_text", // برای سرچ متنی
"fields": {
"keyword": { "type": "keyword",
"normalizer": "lowercase_normalizer" }, // برای فیلتر و facet
"autocomplete": { ... } // برای type-ahead
}
}
```
<div dir="rtl">
متن آنالایزشده برای فیلتر دقیق و شمارش aggregation قابل استفاده نیست — «project management» به دو توکن شکسته می‌شود و در facet دو ردیف جدا می‌سازد. زیرفیلد `keyword` مقدار خام (فقط lowercase و ascii-folded) را نگه می‌دارد.
### ۲.۳ `dynamic: strict`
mapping عمداً strict است. اگر داکیومنتی فیلدی داشته باشد که در mapping نیست، Elasticsearch کل داکیومنت را رد می‌کند — به‌جای اینکه بی‌صدا نوعش را حدس بزند و بعداً یک facet را خراب کند. یک تست (`test_document_only_contains_mapped_fields`) این قرارداد را قفل کرده است.
### ۲.۴ چه چیزی ایندکس **نمی‌شود**
ایمیل، شماره تلفن و آدرس خیابان **هرگز** وارد ایندکس نمی‌شوند. دلیل صرفاً «حریم خصوصی به‌طور کلی» نیست: اگر ایمیل ایندکس شود، هر کاربری می‌تواند با یک سرچ حدسی وجود یک آدرس ایمیل را تأیید کند (oracle attack). این داده‌ها فقط در Postgres می‌مانند و در لایه سریالایزر ماسک می‌شوند.
</div>
---
## ۳. کوئری کلیدواژه
```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` | حرف اول باید دقیق باشد؛ جلوی انفجار کاندیدها و نتایج بی‌ربط را می‌گیرد |
### ۳.۲ بوست عبارت
<div dir="rtl">
کلاز `should` (با `boost: 4`) هیچ‌وقت نتیجه‌ای را حذف نمی‌کند، فقط امتیاز اضافه می‌کند. اثرش:
| کوئری | بدون بوست عبارت | با بوست عبارت |
|---|---|---|
| `product manager` | کسی که «product» در عنوان و «manager» در خلاصه دارد ممکن است اول بیاید | کسی که عنوانش دقیقاً «product manager» است اول می‌آید |
`slop: 1` اجازه می‌دهد یک کلمه فاصله بیفتد، پس «product marketing manager» هم بوست می‌گیرد ولی کمتر.
</div>
---
## ۴. فیلترها
### ۴.۱ نگاشت پارامتر به فیلد ایندکس
```python
FILTER_FIELDS = {
"skills": "skills.keyword",
"roles": "job_title_role",
"seniority": "seniority_levels",
"countries": "location_country",
"industries": "industry.keyword",
"companies": "company.keyword",
}
```
<div dir="rtl">
هر کدام به یک کلاز `terms` تبدیل می‌شوند و بازه تجربه به یک `range`.
### ۴.۲ قاعده OR/AND
</div>
```
?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 با قبلی
]
```
<div dir="rtl">
**چرا این قاعده؟** چون با مدل ذهنی کاربر از یک پنل فیلتر می‌خواند:
- تیک زدن مهارت دومِ **همان دسته** = «یا این یا آن» ⇒ نتایج بیشتر می‌شوند.
- تیک زدن یک کشور در دسته **دیگر** = «و همچنین» ⇒ نتایج کمتر می‌شوند.
اگر مهارت‌ها هم AND می‌شدند، کاربر با تیک زدن مهارت دوم معمولاً به صفر نتیجه می‌رسید و فکر می‌کرد سیستم خراب است.
### ۴.۳ هر دو نگارش کوئری‌استرینگ پشتیبانی می‌شوند
</div>
```
?skills=python,django # جدا با کاما
?skills=python&skills=django # پارامتر تکراری
```
<div dir="rtl">
`CommaSeparatedField.get_value()` در حالت دوم از `QueryDict.getlist` استفاده می‌کند (متد `get` فقط آخرین مقدار را برمی‌گرداند — یک باگ کلاسیک). هر دو نگارش نرمال، lowercase، یکتا و به حداکثر ۲۰ مقدار محدود می‌شوند.
</div>
---
## ۵. 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 } }
}
```
<div dir="rtl">
facetها روی **مجموعه نتایج فیلترشده فعلی** محاسبه می‌شوند، نه روی کل دیتاست. یعنی وقتی کاربر «engineering» را انتخاب کرده، شمارش کشورها فقط مهندس‌ها را می‌شمارد.
دو جزئیات UX که در فرانت‌اند رعایت شده‌اند:
1. **مقدار انتخاب‌شده هیچ‌وقت ناپدید نمی‌شود** — حتی اگر شمارشش صفر شود، وگرنه کاربر نمی‌تواند تیکش را بردارد.
2. **رشته خالی از facet حذف می‌شود** — دیتاست پر از فیلدهای خالی است و یک باکت بی‌نام فقط سردرگمی می‌سازد.
`facets=false` را می‌شود فرستاد تا aggregation اجرا نشود (برای صفحات بعدی که پنل فیلتر تغییری نکرده).
</div>
---
## ۶. مرتب‌سازی
| `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` |
<div dir="rtl">
**نکته `missing: _last`:** ۶۴ پروفایل از ۲۸۴ سال تجربه ندارند. بدون این تنظیم، مرتب‌سازی صعودی همه‌شان را اول می‌گذارد و صفحه اول پر از داده ناقص می‌شود.
**نکته tie-break:** همه‌جا `id` به‌عنوان معیار آخر اضافه شده تا صفحه‌بندی پایدار باشد؛ بدون آن، دو رکورد با امتیاز برابر می‌توانند بین صفحه ۱ و ۲ جابه‌جا شوند و یکی دوبار دیده و دیگری اصلاً دیده نشود.
</div>
---
## ۷. Highlight
```jsonc
"highlight": {
"pre_tags": ["<mark>"], "post_tags": ["</mark>"],
"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 }
}
}
```
<div dir="rtl">
`number_of_fragments: 0` یعنی «کل فیلد را برگردان، نه یک تکه» — برای نام و عنوان که کوتاه‌اند درست است. برای `summary` یک قطعه ۱۶۰ کاراکتری کافی است.
**نکته امنیتی:** snippet برگشتی شامل تگ HTML است ولی محتوایش داده کاربر است. فرانت‌اند آن را با `dangerouslySetInnerHTML` رندر **نمی‌کند**؛ کامپوننت `Highlight` رشته را روی تگ‌های `<mark>` می‌شکند و بقیه را به‌عنوان text node رندر می‌کند. نتیجه یکسان، بدون سطح حمله XSS.
</div>
---
## ۸. بک‌اند 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")
))
```
<div dir="rtl">
وزن‌های پیش‌فرض PostgreSQL: A=1.0، B=0.4، C=0.2، D=0.1 — همان سلسله‌مراتبی که در ES با بوست فیلدها ساخته شد.
### ۸.۲ تحمل غلط تایپی
</div>
```python
.annotate(
text_rank = SearchRank(F("search_vector"), query),
name_similarity= TrigramSimilarity("full_name", term),
)
.annotate(rank=Greatest(F("text_rank"), <name_similarity اگر ≥ 0.3>))
.filter(Q(search_vector=query) | Q(name_similarity__gte=0.3))
```
<div dir="rtl">
`Greatest` یعنی یک تطابق قوی نام به‌اندازه یک تطابق قوی متنی ارزش دارد. آستانه ۰٫۳ تجربی است: پایین‌تر نویز می‌آورد، بالاتر «jhon» را رد می‌کند.
### ۸.۳ نگارش کوئری
`search_type="websearch"` استفاده شده، پس کاربر همان نحوی را دارد که از موتورهای جستجو می‌شناسد:
</div>
```
python django دو کلمه، هر دو لازم (AND)
"product manager" عبارت دقیق
python or ruby یکی از دو
python -junior شامل python، بدون junior
```
### ۸.۴ تفاوت‌های آگاهانه با Elasticsearch
<div dir="rtl">
| موضوع | Elasticsearch | PostgreSQL |
|---|---|---|
| چند کلمه | ۷۰٪ کافی است (بازیابی بالاتر) | همه لازم‌اند (دقت بالاتر) |
| غلط تایپی | روی همه فیلدها | فقط روی نام |
| highlight | highlighter بومی | جایگزینی توکن سمت پایتون |
| facet | aggregation | شمارش در پایتون روی مجموعه فیلترشده |
| مقیاس | میلیون‌ها رکورد | تا حدود ده‌ها هزار رکورد راحت |
این تفاوت‌ها پنهان نشده‌اند: پاسخ API فیلد `backend` دارد و UI هم موتور فعال را نشان می‌دهد. برای دیتاستی با ۲۸۴ رکورد، تفاوت عملاً محسوس نیست — که دقیقاً چیزی است که یک fallback خوب را ممکن می‌کند.
</div>
---
## ۹. محافظ‌ها
<div dir="rtl">
| محدودیت | مقدار | چرا |
|---|---|---|
| طول `q` | ۲۰۰ کاراکتر | کوئری غول‌پیکر = مصرف CPU |
| تعداد مقدار در هر فیلتر | ۲۰ | جلوگیری از `terms` با هزاران مقدار |
| `page_size` | حداکثر ۱۰۰ | جلوگیری از پاسخ چندمگابایتی |
| `page × page_size` | حداکثر ۱۰٬۰۰۰ | صفحه‌بندی عمیق برای انسان بی‌فایده و برای موتور گران است — رد می‌شود، نه اینکه بی‌صدا کوتاه شود |
| rate limit سرچ | ۱۲۰/دقیقه | سرچ گران‌ترین endpoint است |
همه در `SearchRequestSerializer` اعمال می‌شوند، یعنی **قبل** از اینکه چیزی به موتور برسد.
</div>
---
## ۱۰. نمونه‌های عملی
```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
```