371 lines
16 KiB
Markdown
371 lines
16 KiB
Markdown
|
|
# منطق سرچ و فیلترها
|
|||
|
|
|
|||
|
|
<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
|
|||
|
|
```
|