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
16 KiB
منطق سرچ و فیلترها
این سند دقیقاً توضیح میدهد یک کوئری چطور به نتیجه تبدیل میشود: چه چیزی امتیاز میگیرد، چه چیزی فقط حذف میکند، و چرا.
۱. آناتومی یک درخواست
| بخش | نقش | امتیاز میدهد؟ |
|---|---|---|
q |
تعیین میکند چه کسی مرتبط است | ✅ |
| فیلترها | تعیین میکنند چه کسی واجد شرایط است | ❌ |
sort |
ترتیب نهایی | ❌ |
page / page_size |
برش | ❌ |
این تفکیک در Elasticsearch بهصورت مستقیم به must/should (بخش امتیازدهی) در برابر filter (بخش حذف) نگاشت میشود. مزیت عملی: کلازهای filter توسط ES کش میشوند و هزینه محاسبه امتیاز ندارند.
۲. ایندکس Elasticsearch
۲.۱ آنالایزرها
"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"] } // زمان کوئری
سه نکته:
asciifoldingباعث میشود «José» با «jose» پیدا شود.english_stemmer«engineering»، «engineer» و «engineers» را یکی میکند.- آنالایزر متفاوت برای ایندکس و سرچ در autocomplete حیاتی است: اگر عبارت کاربر هم n-gram شود، «jo» با هر چیزی که حرف «j» دارد مچ میشود. با
search_analyzerجدا، فقط پیشوندها مچ میشوند.
۲.۲ چرا هر فیلد دو نسخه دارد
"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 میمانند و در لایه سریالایزر ماسک میشوند.
۳. کوئری کلیدواژه
# 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» هم بوست میگیرد ولی کمتر.
۴. فیلترها
۴.۱ نگاشت پارامتر به فیلد ایندکس
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ها
"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 که در فرانتاند رعایت شدهاند:
- مقدار انتخابشده هیچوقت ناپدید نمیشود — حتی اگر شمارشش صفر شود، وگرنه کاربر نمیتواند تیکش را بردارد.
- رشته خالی از 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
"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 }
}
}
number_of_fragments: 0 یعنی «کل فیلد را برگردان، نه یک تکه» — برای نام و عنوان که کوتاهاند درست است. برای summary یک قطعه ۱۶۰ کاراکتری کافی است.
نکته امنیتی: snippet برگشتی شامل تگ HTML است ولی محتوایش داده کاربر است. فرانتاند آن را با dangerouslySetInnerHTML رندر نمیکند؛ کامپوننت Highlight رشته را روی تگهای <mark> میشکند و بقیه را بهعنوان text node رندر میکند. نتیجه یکسان، بدون سطح حمله XSS.
۸. بکاند PostgreSQL
۸.۱ tsvector وزندار
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 با بوست فیلدها ساخته شد.
۸.۲ تحمل غلط تایپی
.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))
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 اعمال میشوند، یعنی قبل از اینکه چیزی به موتور برسد.
۱۰. نمونههای عملی
# سادهترین حالت: همه، مرتب بر اساس نام
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