Files
cyberyan_linkedin_search/docs/SEARCH.md
T
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task.

Backend (Django 5 / DRF)
- Normalised PostgreSQL model (Profile, Company, School, Skill, Experience,
  Education) as the source of truth, with GIN + trigram indexes.
- Self-healing CSV reader: the supplied export carries an injected watermark
  column, missing columns, rows split by broken quoting and a duplicated
  header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean
  records out of 336 physical rows; only 10 rows parse correctly without it.
- Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL
  full-text search as a fallback selected by a single environment variable.
- Keyword search with per-field boosts, fuzziness and phrase boosting, six
  faceted filters (OR within a filter, AND across filters), an experience
  range, four sort orders, highlighting and facet aggregations.
- JWT auth with rotation + blacklist, three roles, PII masking, scoped rate
  limits, a single error envelope, OpenAPI schema and health probes.

Frontend (React 18 / TypeScript / Vite)
- Debounced search with type-ahead, faceted filter panel, result cards,
  profile drawer, URL-synced search state, automatic token refresh.
- Highlight snippets rendered without dangerouslySetInnerHTML.

Infrastructure
- Hardened multi-stage images (non-root, read-only rootfs, healthchecks).
- docker-compose for the full stack plus dev and production overlays.
- ELK: structured JSON logs shipped asynchronously to Logstash, routed per
  event type, with a composable index template and a Kibana setup script.

Quality
- 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc,
  GitHub Actions running lint, tests and both image builds.
- Persian documentation: README plus architecture, search, API, data,
  security and operations guides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
2026-09-03 08:13:50 +00:00

371 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# منطق سرچ و فیلترها
<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
```