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

16 KiB
Raw Blame History

منطق سرچ و فیلترها

این سند دقیقاً توضیح می‌دهد یک کوئری چطور به نتیجه تبدیل می‌شود: چه چیزی امتیاز می‌گیرد، چه چیزی فقط حذف می‌کند، و چرا.


۱. آناتومی یک درخواست

بخش نقش امتیاز می‌دهد؟
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"] } // زمان کوئری

سه نکته:

  1. asciifolding باعث می‌شود «José» با «jose» پیدا شود.
  2. english_stemmer «engineering»، «engineer» و «engineers» را یکی می‌کند.
  3. آنالایزر متفاوت برای ایندکس و سرچ در 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 که در فرانت‌اند رعایت شده‌اند:

  1. مقدار انتخاب‌شده هیچ‌وقت ناپدید نمی‌شود — حتی اگر شمارشش صفر شود، وگرنه کاربر نمی‌تواند تیکش را بردارد.
  2. رشته خالی از 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