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
This commit is contained in:
+370
@@ -0,0 +1,370 @@
|
||||
# منطق سرچ و فیلترها
|
||||
|
||||
<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
|
||||
```
|
||||
Reference in New Issue
Block a user