Elasticsearch چیست؟ آموزش کامل ساخت موتور جستوجوی فارسی با Python و FastAPI
Elasticsearch یک موتور جستوجوی سریع و مقیاسپذیر است. در این آموزش، نصب و راهاندازی، Index، Mapping، Query DSL و ساخت موتور جستوجوی فارسی با Python، FastAPI و API درواره را عملی یاد میگیرید.
وقتی تعداد محصولات، مقالهها، فایلها یا رکوردهای یک برنامه افزایش پیدا میکند، جستوجوی ساده با SQL دیگر همیشه پاسخگوی نیازهای واقعی کاربران نیست. کاربران انتظار دارند حتی با نوشتن بخشی از یک عبارت، جابهجا نوشتن کلمات یا واردکردن شکلهای مختلف حروف فارسی، نتیجه مرتبط را در چند میلیثانیه دریافت کنند.
Elasticsearch یا «الستیک سرچ» یک موتور جستوجو و تحلیل داده توزیعشده است که برای همین نوع مسئله طراحی شده است. این ابزار میتواند حجم زیادی از دادههای متنی و ساختاریافته را ایندکس کند و با استفاده از قابلیتهایی مانند Full-Text Search، فیلتر، مرتبسازی، Aggregation و امتیازدهی BM25 نتایج مرتبط را برگرداند.
در این آموزش ابتدا با معماری و مفاهیم اصلی Elasticsearch آشنا میشویم، سپس آن را با Docker اجرا میکنیم، یک Index مناسب زبان فارسی میسازیم و در نهایت یک API جستوجوی واقعی با Python و FastAPI توسعه میدهیم. در بخش پایانی نیز نتایج جستوجو را به API درواره متصل میکنیم تا کاربر بتواند علاوه بر نتایج خام، یک پاسخ خلاصه و مبتنی بر همان نتایج دریافت کند.
Elasticsearch چیست؟
Elasticsearch یک موتور Search و Analytics مبتنی بر Apache Lucene است. دادهها در آن بهصورت Documentهای JSON ذخیره میشوند و از طریق REST API قابل نوشتن، خواندن، جستوجو و تحلیل هستند.
برخلاف یک پایگاه داده رابطهای که معمولاً برای اجرای تراکنش، Join و نگهداری داده اصلی برنامه طراحی میشود، Elasticsearch عمدتاً برای این کاربردها مناسب است:
- جستوجوی متن کامل یا Full-Text Search
- جستوجوی محصولات فروشگاه
- جستوجوی مقالهها و مستندات
- پیشنهاد عبارت هنگام تایپ
- فیلتر و مرتبسازی سریع
- تحلیل و دستهبندی داده
- ساخت داشبوردهای تحلیلی
- جستوجوی لاگها
- بازیابی اطلاعات برای سامانههای هوش مصنوعی
- ساخت لایه Search برای وبسایت و اپلیکیشن
Elasticsearch معمولاً جایگزین کامل PostgreSQL، MySQL یا MongoDB نیست. در یک معماری متداول، پایگاه داده اصلی منبع نهایی حقیقت یا Source of Truth باقی میماند و نسخهای از دادههای قابل جستوجو در Elasticsearch ایندکس میشود.
برای مشاهده تعریف و قابلیتهای رسمی میتوانید به مستندات Elasticsearch مراجعه کنید.
Elasticsearch چه تفاوتی با SQL دارد؟
فرض کنید در PostgreSQL جدولی از محصولات دارید و میخواهید محصولاتی را پیدا کنید که عبارت «گوشی سامسونگ» در عنوان آنها وجود دارد. یک جستوجوی ساده ممکن است به این شکل باشد:
SELECT *
FROM products
WHERE title ILIKE '%گوشی سامسونگ%';
این Query برای دادههای محدود قابل استفاده است، اما چند مشکل دارد:
- میزان ارتباط هر نتیجه را مشخص نمیکند.
- شکلهای مختلف «ی» و «ک» فارسی و عربی ممکن است نتایج متفاوتی بدهند.
- جابهجایی کلمات میتواند نتیجه را تغییر دهد.
- جستوجو در چند فیلد و وزندهی به آنها دشوارتر میشود.
- قابلیتهایی مانند Highlight، Fuzzy Search و تحلیل متن به پیادهسازی بیشتری نیاز دارند.
- جستوجوی Wildcard روی حجم زیاد داده میتواند پرهزینه شود.
Elasticsearch پیش از جستوجو متن را تحلیل و به Tokenهای قابل بازیابی تبدیل میکند. سپس بهجای پیمایش همه Documentها، از ساختاری به نام Inverted Index استفاده میکند.
Inverted Index چگونه کار میکند؟
فرض کنید سه عنوان زیر را داریم:
- آموزش برنامهنویسی Python
- آموزش FastAPI با Python
- ساخت موتور جستوجو با Elasticsearch
Elasticsearch پس از تحلیل متن، ساختاری شبیه این مفهوم ایجاد میکند:
| واژه | Documentهای شامل واژه |
|---|---|
| آموزش | ۱ و ۲ |
| Python | ۱ و ۲ |
| FastAPI | ۲ |
| موتور | ۳ |
| جستوجو | ۳ |
| Elasticsearch | ۳ |
هنگامی که کاربر «آموزش Python» را جستوجو میکند، Elasticsearch لازم نیست تمام متن همه رکوردها را از ابتدا بررسی کند. موتور جستوجو با مراجعه به Inverted Index، Documentهای مرتبط را سریع پیدا و براساس امتیاز ارتباط مرتب میکند.
مفاهیم اصلی Elasticsearch
پیش از شروع کدنویسی باید چند مفهوم اصلی را بشناسیم.
Cluster
Cluster مجموعهای از یک یا چند Node است که با هم یک سامانه Elasticsearch را تشکیل میدهند. برای محیط توسعه، یک Cluster تکگرهای کافی است.
Node
هر نمونه در حال اجرای Elasticsearch یک Node نامیده میشود. در محیط Production میتوان چند Node داشت تا بار پردازش و ذخیرهسازی میان آنها تقسیم شود.
Index
Index مجموعهای از Documentهای مرتبط است. میتوان آن را تا حدودی با Table در پایگاه داده رابطهای مقایسه کرد، اما این تشبیه کاملاً دقیق نیست.
نمونه Indexها:
products
articles
customers
support_tickets
نام Index بهتر است با حروف کوچک نوشته شود.
Document
هر رکورد JSON داخل یک Index یک Document است:
{
"title": "گوشی هوشمند مدل X",
"description": "گوشی با نمایشگر OLED و حافظه ۲۵۶ گیگابایت",
"category": "mobile",
"price": 42000000,
"available": true
}
Field
هر ویژگی Document یک Field است؛ مانند title، price یا category.
Mapping
Mapping نوع و رفتار هر Field را مشخص میکند. برای مثال:
textبرای متن قابل جستوجوkeywordبرای مقدار دقیقintegerوfloatبرای عددbooleanبرای درست یا نادرستdateبرای تاریخobjectوnestedبرای ساختارهای تودرتو
Shard
هر Index میتواند به چند Shard تقسیم شود. Shard امکان توزیع داده و پردازش روی چند Node را فراهم میکند.
Replica
Replica یک نسخه اضافی از Shard است که برای افزایش دسترسپذیری و توزیع خواندن استفاده میشود. در محیط تکگرهای توسعه معمولاً تعداد Replica را صفر قرار میدهیم.
تفاوت text و keyword
یکی از مهمترین تصمیمها در طراحی Mapping انتخاب درست میان text و keyword است.
| نوع | کاربرد |
|---|---|
text | جستوجوی متن کامل و تحلیلشده |
keyword | فیلتر، مرتبسازی، Aggregation و تطبیق دقیق |
برای مثال، عنوان محصول باید قابل جستوجو باشد و در نتیجه از نوع text تعریف میشود. اما دستهبندی محصول معمولاً باید دقیقاً با مقداری مانند mobile یا laptop تطبیق داده شود؛ بنابراین نوع keyword برای آن مناسبتر است.
یک Field میتواند همزمان هر دو رفتار را داشته باشد:
"title": {
"type": "text",
"analyzer": "persian",
"fields": {
"keyword": {
"type": "keyword"
}
}
}
در این حالت:
titleبرای Full-Text Search استفاده میشود.title.keywordبرای مرتبسازی یا تطبیق دقیق قابل استفاده است.
Analyzer در Elasticsearch چیست؟
Analyzer مشخص میکند متن هنگام Index و Search چگونه پردازش شود. یک Analyzer معمولاً از سه بخش تشکیل میشود:
- Character Filter برای اصلاح اولیه حروف و نشانهها
- Tokenizer برای تقسیم متن به Token
- Token Filter برای نرمالسازی یا حذف بعضی Tokenها
در زبان فارسی، نرمالسازی اهمیت زیادی دارد. کاربر ممکن است «ی» را به شکل فارسی یا عربی بنویسد. همین مسئله درباره «ک» نیز وجود دارد. فاصله، نیمفاصله و ارقام فارسی و انگلیسی هم میتوانند روی نتیجه اثر بگذارند.
Elasticsearch یک Analyzer داخلی برای زبان فارسی دارد که نقطه شروع مناسبی برای پروژههای فارسی است:
{
"type": "text",
"analyzer": "persian"
}
برای پروژههای حرفهای باید Analyzer را با داده واقعی، Queryهای کاربران و معیارهای ارزیابی تست کنید. هیچ Analyzer واحدی برای همه فروشگاهها، وبسایتها و مجموعهدادهها بهترین گزینه نیست.
نصب Elasticsearch با Docker
برای اجرای محلی به Docker و Docker Compose نیاز دارید.
ساختار پروژه را به شکل زیر در نظر بگیرید:
persian-search/
├── docker-compose.yml
├── requirements.txt
├── .env
└── app.py
فایل docker-compose.yml را بسازید:
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION:-8.19.1}
container_name: persian-search-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "127.0.0.1:9200:9200"
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
healthcheck:
test:
[
"CMD-SHELL",
"curl --fail http://localhost:9200/_cluster/health || exit 1"
]
interval: 10s
timeout: 5s
retries: 20
volumes:
elasticsearch_data:
تنظیم xpack.security.enabled=false فقط برای سادهکردن محیط توسعه محلی در نظر گرفته شده است. این نمونه پورت را فقط روی 127.0.0.1 منتشر میکند. همین پیکربندی را بدون بررسی و تنظیم دسترسی، احراز هویت، TLS، Backup و مانیتورینگ در محیط Production استفاده نکنید.
Elasticsearch را اجرا کنید:
docker compose up -d
وضعیت Container را ببینید:
docker compose ps
برای تست سرویس اجرا کنید:
curl http://localhost:9200
اگر سرویس آماده باشد، یک پاسخ JSON شامل اطلاعات Node و نسخه Elasticsearch دریافت میکنید.
برای مشاهده سلامت Cluster:
curl http://localhost:9200/_cluster/health?pretty
در Cluster تکگرهای ممکن است وضعیت Yellow مشاهده شود اگر Index دارای Replica باشد. در پروژه ما تعداد Replica را صفر قرار میدهیم تا وضعیت محیط محلی Green شود.
ساخت Index مناسب جستوجوی فارسی
اکنون یک Index برای محصولات میسازیم:
curl -X PUT "http://localhost:9200/products-v1" \
-H "Content-Type: application/json" \
-d '{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"dynamic": "strict",
"properties": {
"title": {
"type": "text",
"analyzer": "persian",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"description": {
"type": "text",
"analyzer": "persian"
},
"category": {
"type": "keyword"
},
"tags": {
"type": "keyword"
},
"price": {
"type": "long"
},
"available": {
"type": "boolean"
},
"created_at": {
"type": "date"
}
}
}
}'
استفاده از "dynamic": "strict" باعث میشود ورود Fieldهای تعریفنشده با خطا مواجه شود. این رفتار برای پروژههایی که Schema مشخصی دارند مفید است، زیرا اشتباه تایپی یا تغییر ناخواسته ساختار داده را زودتر آشکار میکند.
اگر ساختار داده شما پویا است، میتوانید Dynamic Mapping را فعال نگه دارید؛ اما باید مراقب Mapping Explosion و ایجاد تعداد بسیار زیاد Field باشید.
واردکردن Document
یک محصول را با شناسه مشخص ثبت میکنیم:
curl -X PUT "http://localhost:9200/products-v1/_doc/1" \
-H "Content-Type: application/json" \
-d '{
"title": "لپتاپ حرفهای مناسب برنامهنویسی",
"description": "لپتاپ با حافظه ۱۶ گیگابایت و پردازنده قدرتمند برای توسعه نرمافزار",
"category": "laptop",
"tags": ["programming", "developer"],
"price": 65000000,
"available": true,
"created_at": "2026-08-06T10:00:00Z"
}'
برای خواندن Document:
curl "http://localhost:9200/products-v1/_doc/1?pretty"
برای بهروزرسانی بخشی از آن:
curl -X POST "http://localhost:9200/products-v1/_update/1" \
-H "Content-Type: application/json" \
-d '{
"doc": {
"price": 63000000,
"available": true
}
}'
برای حذف Document:
curl -X DELETE "http://localhost:9200/products-v1/_doc/1"
واردکردن گروهی داده با Bulk API
ثبت جداگانه هزاران Document باعث افزایش تعداد درخواستهای شبکه میشود. برای ورود گروهی داده از Bulk API استفاده کنید.
فایل products.ndjson میتواند چنین ساختاری داشته باشد:
{"index":{"_index":"products-v1","_id":"1"}}
{"title":"لپتاپ برنامهنویسی مدل Pro","description":"مناسب برنامهنویسی وب و اجرای ابزارهای توسعه","category":"laptop","tags":["programming","developer"],"price":65000000,"available":true,"created_at":"2026-08-06T10:00:00Z"}
{"index":{"_index":"products-v1","_id":"2"}}
{"title":"مانیتور ۲۷ اینچ مخصوص طراحی","description":"نمایشگر با وضوح بالا مناسب طراحی رابط کاربری","category":"monitor","tags":["design","display"],"price":18000000,"available":true,"created_at":"2026-08-06T10:10:00Z"}
{"index":{"_index":"products-v1","_id":"3"}}
{"title":"کیبورد مکانیکی برنامه نویسی","description":"کیبورد مکانیکی کمصدا برای تایپ و کدنویسی طولانی","category":"accessory","tags":["keyboard","programming"],"price":4500000,"available":false,"created_at":"2026-08-06T10:20:00Z"}
هر عملیات و Document باید در یک خط جداگانه قرار بگیرد و فایل با یک خط جدید تمام شود.
ارسال فایل:
curl -X POST "http://localhost:9200/_bulk?refresh=true" \
-H "Content-Type: application/x-ndjson" \
--data-binary "@products.ndjson"
در محیط Production معمولاً نباید پس از هر درخواست refresh=true بفرستید، زیرا میتواند هزینه نوشتن را افزایش دهد. بهتر است فرآیند Refresh را متناسب با نیاز پروژه مدیریت کنید.
اولین جستوجو با match
برای جستوجوی عبارت «لپ تاپ برنامه نویسی» در عنوان:
curl -X POST "http://localhost:9200/products-v1/_search?pretty" \
-H "Content-Type: application/json" \
-d '{
"query": {
"match": {
"title": "لپ تاپ برنامه نویسی"
}
}
}'
نتایج در مسیر زیر قرار میگیرند:
hits.hits
هر نتیجه معمولاً این اطلاعات را دارد:
_id: شناسه Document_score: امتیاز ارتباط_source: داده اصلی Document
تفاوت match و term
اشتباه رایج در Elasticsearch استفاده از term برای جستوجوی متن تحلیلشده است.
match
Query نوع match متن ورودی را با Analyzer پردازش میکند و برای Full-Text Search مناسب است:
{
"match": {
"title": "برنامه نویسی پایتون"
}
}
term
Query نوع term برای تطبیق دقیق مقدار مناسب است:
{
"term": {
"category": "laptop"
}
}
قاعده عملی:
- برای Field نوع
textمعمولاً ازmatchیاmulti_matchاستفاده کنید. - برای Field نوع
keyword، عدد، Boolean و فیلترهای دقیق ازterm،termsیاrangeاستفاده کنید.
جستوجو در چند Field با multi_match
در یک فروشگاه، عنوان احتمالاً از توضیحات اهمیت بیشتری دارد. میتوان با علامت ^ به Fieldها وزن داد:
curl -X POST "http://localhost:9200/products-v1/_search?pretty" \
-H "Content-Type: application/json" \
-d '{
"query": {
"multi_match": {
"query": "لپ تاپ برنامه نویسی",
"fields": [
"title^4",
"description",
"tags^2"
],
"type": "best_fields",
"fuzziness": "AUTO"
}
}
}'
در این Query:
- تطبیق در
titleچهار برابر وزن پایه دارد. - تطبیق در
tagsوزن بیشتری از توضیحات دارد. fuzzinessمیتواند بعضی خطاهای تایپی را پوشش دهد.
Fuzzy Search را بدون ارزیابی فعال نکنید. روی عبارتهای کوتاه، نام برند، کد محصول و دادههای بزرگ ممکن است نتایج نامرتبط یا هزینه پردازشی بیشتری ایجاد کند.
ترکیب جستوجو و فیلتر با bool
یک Query واقعی معمولاً شامل جستوجوی متنی و چند فیلتر است:
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "لپ تاپ برنامه نویسی",
"fields": ["title^4", "description", "tags^2"]
}
}
],
"filter": [
{
"term": {
"category": "laptop"
}
},
{
"term": {
"available": true
}
},
{
"range": {
"price": {
"gte": 30000000,
"lte": 80000000
}
}
}
]
}
}
}
تفاوت مهم must و filter این است که شرطهای must میتوانند در محاسبه Score اثر داشته باشند، اما filter برای محدودکردن دقیق نتایج استفاده میشود و معمولاً وارد محاسبه امتیاز ارتباط نمیشود.
مرتبسازی نتایج
بهصورت پیشفرض نتایج Full-Text Search براساس _score مرتب میشوند. میتوانید معیار دوم نیز اضافه کنید:
{
"sort": [
{
"_score": "desc"
},
{
"created_at": "desc"
}
]
}
اگر فقط براساس قیمت مرتب کنید، ممکن است ارتباط متنی نادیده گرفته شود:
{
"sort": [
{
"price": "asc"
}
]
}
انتخاب روش مرتبسازی باید با هدف کاربر هماهنگ باشد. برای Queryهای متنی، نگهداشتن _score معمولاً اهمیت زیادی دارد.
Highlight کردن بخش منطبق
برای نمایش قسمت مرتبط عنوان و توضیحات:
{
"query": {
"multi_match": {
"query": "برنامه نویسی",
"fields": ["title", "description"]
}
},
"highlight": {
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
"fields": {
"title": {},
"description": {
"fragment_size": 120,
"number_of_fragments": 2
}
}
}
}
اگر خروجی Highlight را در HTML نمایش میدهید، فقط Tagهای کنترلشده را مجاز کنید و محتوای خام یا تولیدشده توسط کاربر را بدون Escape در صفحه قرار ندهید.
Pagination در Elasticsearch
برای صفحهبندی ساده میتوان از from و size استفاده کرد:
{
"from": 0,
"size": 20,
"query": {
"match": {
"title": "لپ تاپ"
}
}
}
برای صفحه دوم:
{
"from": 20,
"size": 20
}
این روش برای صفحههای ابتدایی مناسب است، اما Deep Pagination هزینه بیشتری دارد. برای پیمایش حجم زیاد نتایج، از search_after همراه با Sort پایدار استفاده کنید:
{
"size": 20,
"query": {
"match": {
"title": "لپ تاپ"
}
},
"sort": [
{
"created_at": "desc"
},
{
"_id": "asc"
}
],
"search_after": [
"2026-08-01T10:00:00Z",
"product-125"
]
}
مقادیر search_after باید از آرایه sort آخرین نتیجه صفحه قبلی گرفته شوند.
Aggregation چیست؟
Aggregation برای محاسبه آمار و ساخت Facet استفاده میشود. برای مثال، میتوان تعداد نتایج هر دستهبندی را محاسبه کرد:
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category"
}
}
}
}
یا میانگین قیمت را به دست آورد:
{
"size": 0,
"aggs": {
"average_price": {
"avg": {
"field": "price"
}
}
}
}
در یک فروشگاه، Facetها میتوانند برای ساخت فیلترهای پویا مانند دستهبندی، وضعیت موجودی و محدوده قیمت استفاده شوند.
Alias و نسخهبندی Index
تغییر بعضی Mappingها روی Index موجود امکانپذیر نیست یا به Reindex نیاز دارد. به همین دلیل بهتر است Indexها را نسخهبندی کنید:
products-v1
products-v2
products-v3
سپس یک Alias پایدار بسازید:
curl -X POST "http://localhost:9200/_aliases" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{
"add": {
"index": "products-v1",
"alias": "products"
}
}
]
}'
برنامه بهجای products-v1 از products استفاده میکند. هنگام انتشار نسخه جدید میتوان Alias را در یک عملیات اتمیک تغییر داد:
{
"actions": [
{
"remove": {
"index": "products-v1",
"alias": "products"
}
},
{
"add": {
"index": "products-v2",
"alias": "products"
}
}
]
}
این روش احتمال قطعی هنگام تغییر Mapping را کاهش میدهد.
پروژه عملی: API جستوجوی فارسی با Python و FastAPI
اکنون یک API واقعی میسازیم که ویژگیهای زیر را دارد:
- ساخت خودکار Index
- ورود داده نمونه
- جستوجو در عنوان و توضیحات
- فیلتر دستهبندی و قیمت
- فیلتر موجودی
- Highlight نتایج
- خلاصهسازی اختیاری نتایج با API درواره
نصب وابستگیها
فایل requirements.txt:
fastapi>=0.115,<1
uvicorn[standard]>=0.34,<1
elasticsearch[async]>=8.19,<9
httpx>=0.28,<1
python-dotenv>=1.0,<2
pydantic>=2.10,<3
نصب پکیجها:
python -m venv .venv
در Linux و macOS:
source .venv/bin/activate
در Windows PowerShell:
.venv\Scripts\Activate.ps1
سپس:
pip install -r requirements.txt
تنظیم متغیرهای محیطی
فایل .env:
ELASTICSEARCH_URL=http://localhost:9200
ELASTICSEARCH_INDEX=products
DARVAREH_API_KEY=YOUR_DARVAREH_API_KEY
DARVAREH_MODEL_ID=YOUR_MODEL_ID
کلید API را در Source Code یا مخزن Git قرار ندهید. فایل .env را به .gitignore اضافه کنید.
کد کامل FastAPI
فایل app.py:
import json
import os
from contextlib import asynccontextmanager
from typing import Any
import httpx
from dotenv import load_dotenv
from elasticsearch import AsyncElasticsearch
from elasticsearch.helpers import async_bulk
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
load_dotenv()
ELASTICSEARCH_URL = os.getenv(
"ELASTICSEARCH_URL",
"http://localhost:9200",
)
INDEX_NAME = os.getenv("ELASTICSEARCH_INDEX", "products")
DARVAREH_API_KEY = os.getenv("DARVAREH_API_KEY", "")
DARVAREH_MODEL_ID = os.getenv(
"DARVAREH_MODEL_ID",
"YOUR_MODEL_ID",
)
es = AsyncElasticsearch(
ELASTICSEARCH_URL,
request_timeout=10,
retry_on_timeout=True,
max_retries=2,
)
INDEX_DEFINITION = {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
},
"mappings": {
"dynamic": "strict",
"properties": {
"title": {
"type": "text",
"analyzer": "persian",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256,
}
},
},
"description": {
"type": "text",
"analyzer": "persian",
},
"category": {
"type": "keyword",
},
"tags": {
"type": "keyword",
},
"price": {
"type": "long",
},
"available": {
"type": "boolean",
},
"created_at": {
"type": "date",
},
},
},
}
SAMPLE_PRODUCTS = [
{
"_id": "1",
"title": "لپتاپ حرفهای مناسب برنامهنویسی",
"description": (
"لپتاپ با حافظه ۱۶ گیگابایت، حافظه SSD "
"و پردازنده مناسب توسعه نرمافزار"
),
"category": "laptop",
"tags": ["programming", "developer"],
"price": 65000000,
"available": True,
"created_at": "2026-08-06T10:00:00Z",
},
{
"_id": "2",
"title": "مانیتور ۲۷ اینچ مناسب طراحی و کدنویسی",
"description": (
"نمایشگر با وضوح بالا و پنل مناسب کار طولانی، "
"طراحی رابط کاربری و برنامهنویسی"
),
"category": "monitor",
"tags": ["display", "design", "programming"],
"price": 18000000,
"available": True,
"created_at": "2026-08-06T10:10:00Z",
},
{
"_id": "3",
"title": "کیبورد مکانیکی کمصدا",
"description": (
"کیبورد مناسب تایپ طولانی و توسعه نرمافزار "
"با کلیدهای مکانیکی کمصدا"
),
"category": "accessory",
"tags": ["keyboard", "programming"],
"price": 4500000,
"available": False,
"created_at": "2026-08-06T10:20:00Z",
},
]
class ProductCreate(BaseModel):
id: str = Field(min_length=1, max_length=100)
title: str = Field(min_length=2, max_length=300)
description: str = Field(min_length=2, max_length=5000)
category: str = Field(min_length=1, max_length=100)
tags: list[str] = Field(default_factory=list)
price: int = Field(ge=0)
available: bool = True
created_at: str
async def create_index() -> None:
exists = await es.indices.exists(index=INDEX_NAME)
if not exists:
await es.indices.create(
index=INDEX_NAME,
**INDEX_DEFINITION,
)
actions = []
for product in SAMPLE_PRODUCTS:
source = {
key: value
for key, value in product.items()
if key != "_id"
}
actions.append(
{
"_op_type": "index",
"_index": INDEX_NAME,
"_id": product["_id"],
"_source": source,
}
)
await async_bulk(es, actions)
await es.indices.refresh(index=INDEX_NAME)
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
if not await es.ping():
raise RuntimeError(
"Elasticsearch is not available"
)
await create_index()
yield
finally:
await es.close()
app = FastAPI(
title="Persian Product Search API",
version="1.0.0",
lifespan=lifespan,
)
def build_query(
q: str,
category: str | None,
min_price: int | None,
max_price: int | None,
available: bool | None,
) -> dict[str, Any]:
filters: list[dict[str, Any]] = []
if category:
filters.append(
{
"term": {
"category": category,
}
}
)
if available is not None:
filters.append(
{
"term": {
"available": available,
}
}
)
if min_price is not None or max_price is not None:
price_range: dict[str, int] = {}
if min_price is not None:
price_range["gte"] = min_price
if max_price is not None:
price_range["lte"] = max_price
filters.append(
{
"range": {
"price": price_range,
}
}
)
return {
"bool": {
"must": [
{
"multi_match": {
"query": q,
"fields": [
"title^4",
"description",
"tags^2",
],
"type": "best_fields",
"operator": "or",
"minimum_should_match": "60%",
}
}
],
"filter": filters,
}
}
async def summarize_with_darvareh(
user_query: str,
results: list[dict[str, Any]],
) -> str | None:
if not DARVAREH_API_KEY:
return None
context = json.dumps(
results,
ensure_ascii=False,
indent=2,
)
messages = [
{
"role": "system",
"content": (
"شما دستیار جستوجوی محصول هستید. "
"فقط براساس نتایج ارائهشده پاسخ دهید. "
"اگر اطلاعات کافی نیست، صریحاً اعلام کنید. "
"قیمت یا ویژگی جدیدی اختراع نکنید."
),
},
{
"role": "user",
"content": (
f"عبارت جستوجوی کاربر:\n{user_query}\n\n"
f"نتایج بازیابیشده:\n{context}\n\n"
"در یک پاراگراف کوتاه، مناسبترین گزینهها "
"را مقایسه و خلاصه کن."
),
},
]
headers = {
"Authorization": f"Bearer {DARVAREH_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": DARVAREH_MODEL_ID,
"messages": messages,
"temperature": 0.2,
"max_tokens": 500,
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
"https://api.darvareh.ir/v1/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
@app.get("/health")
async def health():
try:
cluster_health = await es.cluster.health()
return {
"status": "ok",
"elasticsearch": cluster_health["status"],
}
except Exception as exc:
raise HTTPException(
status_code=503,
detail="Search service is unavailable",
) from exc
@app.post("/products", status_code=201)
async def create_product(product: ProductCreate):
document = product.model_dump()
document_id = document.pop("id")
response = await es.index(
index=INDEX_NAME,
id=document_id,
document=document,
refresh="wait_for",
)
return {
"id": response["_id"],
"result": response["result"],
}
@app.get("/search")
async def search_products(
q: str = Query(min_length=2, max_length=200),
category: str | None = None,
min_price: int | None = Query(default=None, ge=0),
max_price: int | None = Query(default=None, ge=0),
available: bool | None = None,
page: int = Query(default=1, ge=1, le=100),
size: int = Query(default=10, ge=1, le=50),
summarize: bool = False,
):
if (
min_price is not None
and max_price is not None
and min_price > max_price
):
raise HTTPException(
status_code=422,
detail="min_price cannot be greater than max_price",
)
query = build_query(
q=q,
category=category,
min_price=min_price,
max_price=max_price,
available=available,
)
response = await es.search(
index=INDEX_NAME,
from_=(page - 1) * size,
size=size,
query=query,
sort=[
{"_score": {"order": "desc"}},
{"created_at": {"order": "desc"}},
],
highlight={
"pre_tags": ["<mark>"],
"post_tags": ["</mark>"],
"fields": {
"title": {},
"description": {
"fragment_size": 140,
"number_of_fragments": 1,
},
},
},
aggs={
"categories": {
"terms": {
"field": "category",
"size": 20,
}
}
},
)
items = []
for hit in response["hits"]["hits"]:
items.append(
{
"id": hit["_id"],
"score": hit["_score"],
**hit["_source"],
"highlight": hit.get("highlight", {}),
}
)
total_data = response["hits"]["total"]
total = (
total_data["value"]
if isinstance(total_data, dict)
else total_data
)
summary = None
if summarize and items:
try:
summary = await summarize_with_darvareh(
user_query=q,
results=items[:5],
)
except httpx.HTTPError:
summary = (
"نتایج جستوجو آماده است، اما تولید خلاصه "
"در این لحظه انجام نشد."
)
category_buckets = response["aggregations"][
"categories"
]["buckets"]
return {
"query": q,
"page": page,
"size": size,
"total": total,
"items": items,
"facets": {
"categories": [
{
"value": bucket["key"],
"count": bucket["doc_count"],
}
for bucket in category_buckets
]
},
"summary": summary,
}
اجرای پروژه
ابتدا مطمئن شوید Elasticsearch در حال اجرا است:
docker compose up -d
سپس FastAPI را اجرا کنید:
uvicorn app:app --reload
مستندات تعاملی API در این آدرس در دسترس است:
http://127.0.0.1:8000/docs
بررسی سلامت:
curl "http://127.0.0.1:8000/health"
اجرای جستوجوی ساده:
curl --get "http://127.0.0.1:8000/search" \
--data-urlencode "q=وسیله مناسب برنامه نویسی"
جستوجو با فیلتر:
curl --get "http://127.0.0.1:8000/search" \
--data-urlencode "q=لپ تاپ برنامه نویسی" \
--data-urlencode "category=laptop" \
--data-urlencode "available=true" \
--data-urlencode "min_price=30000000" \
--data-urlencode "max_price=80000000"
جستوجو همراه با خلاصه هوش مصنوعی:
curl --get "http://127.0.0.1:8000/search" \
--data-urlencode "q=برای برنامه نویسی چه محصولی مناسب است؟" \
--data-urlencode "summarize=true"
اگر summarize=false باشد، فقط Elasticsearch فراخوانی میشود و هزینهای برای مدل هوش مصنوعی ایجاد نمیشود.
برای دریافت کلید API، مشاهده سرویسها و اتصال برنامه خود به مدلهای مختلف میتوانید وارد درواره شوید. شناسه مدلهای قابل استفاده و قیمت بهروز هر مدل در صفحه مدلها و قیمتهای درواره نمایش داده میشود.
چرا ابتدا Search و سپس هوش مصنوعی؟
ارسال مستقیم همه محصولات به یک مدل زبانی راهکار مناسبی نیست. این روش با افزایش داده باعث بالا رفتن مصرف Token، زمان پاسخ و هزینه میشود و احتمال نادیدهگرفتن اطلاعات مرتبط نیز بیشتر خواهد شد.
معماری مناسبتر شامل دو مرحله است:
- Elasticsearch چند نتیجه مرتبط را بازیابی میکند.
- مدل هوش مصنوعی فقط همان نتایج محدود را خلاصه یا مقایسه میکند.
در این معماری، Elasticsearch مسئول Retrieval و مدل متصلشده از طریق API درواره مسئول تولید پاسخ طبیعی است. برای جلوگیری از پاسخهای ساختگی، Prompt باید مدل را ملزم کند فقط براساس داده بازیابیشده پاسخ دهد.
همگامسازی پایگاه داده اصلی با Elasticsearch
در پروژه واقعی، Elasticsearch معمولاً منبع اصلی داده نیست. اگر محصولی در PostgreSQL یا MongoDB تغییر کرد، نسخه قابل جستوجوی آن نیز باید بهروزرسانی شود.
روشهای رایج همگامسازی عبارتاند از:
همگامسازی مستقیم پس از عملیات
برنامه بعد از ثبت موفق در پایگاه داده، Document را در Elasticsearch نیز ثبت میکند. این روش ساده است، اما اگر یکی از دو عملیات شکست بخورد باید Retry و بازیابی وضعیت را مدیریت کنید.
صف پردازش
پس از تغییر داده، یک Job در Queue قرار میگیرد و Worker آن را در Elasticsearch اعمال میکند. این روش برای بار بیشتر و پردازش غیرهمزمان مناسبتر است.
Outbox Pattern
تغییر اصلی و Event مربوط به آن در یک Transaction پایگاه داده ثبت میشوند. Worker بعداً Event را پردازش و Elasticsearch را بهروزرسانی میکند. این روش احتمال ازدسترفتن Event میان دو سامانه را کاهش میدهد.
بازسازی دورهای Index
برای دادههایی که حساسیت زمانی کمتری دارند، میتوان Index جدید ساخت، دادهها را دوباره وارد کرد و سپس Alias را تغییر داد.
چگونه کیفیت جستوجو را ارزیابی کنیم؟
صرفاً اجراشدن Query به معنی باکیفیتبودن Search نیست. یک مجموعه Query واقعی تهیه کنید:
لپ تاپ برنامه نویسی
لپتاپ برای کدنویسی
مانیتور طراحی
کیبرد مکانیکی
وسیله مناسب تایپ طولانی
برای هر Query مشخص کنید کدام نتایج باید در رتبههای اول باشند. سپس موارد زیر را آزمایش کنید:
- وزن
title - وزن
description - وزن
tags - مقدار
minimum_should_match - فعال یا غیرفعالبودن Fuzziness
- Analyzer فارسی
- مترادفها
- فیلترهای دستهبندی
- رفتار Queryهای کوتاه
- Queryهای بدون نتیجه
معیارهای ساده و کاربردی:
- آیا نتیجه مورد انتظار در رتبه اول است؟
- آیا در سه نتیجه اول قرار دارد؟
- چند Query بدون نتیجه ماندهاند؟
- کاربران روی کدام رتبه کلیک میکنند؟
- چند کاربر بعد از جستوجو Query خود را تغییر میدهند؟
- زمان پاسخ در صدک ۹۵ چقدر است؟
برای ارزیابی واقعی، Queryهای کاربران را پس از حذف یا ناشناسسازی دادههای حساس بررسی کنید و تغییرات Ranking را با تست کنترلشده بسنجید.
بهینهسازی Elasticsearch برای Production
Mapping را پیش از ورود داده طراحی کنید
Dynamic Mapping برای آزمایش سریع مفید است، اما در پروژه پایدار بهتر است نوع Fieldهای اصلی مشخص باشد.
تعداد Shard را بیدلیل افزایش ندهید
Shard بیشتر همیشه به معنی سرعت بیشتر نیست. هر Shard هزینه حافظه، پردازش و مدیریت دارد. تعداد Shard باید براساس حجم داده، تعداد Node و الگوی Query انتخاب شود.
از _source فقط فیلدهای لازم را دریافت کنید
اگر Document بزرگ است، پاسخ را محدود کنید:
{
"_source": [
"title",
"category",
"price",
"available"
]
}
از Bulk برای نوشتن گروهی استفاده کنید
برای ورود انبوه، درخواستهای کوچک و جداگانه نفرستید. اندازه Batch را با تست بار تعیین کنید.
Timeout تعریف کنید
در Client برنامه حتماً Timeout، Retry محدود و مدیریت خطا داشته باشید. Retry نامحدود میتواند در زمان اختلال بار سامانه را بیشتر کند.
Queryهای سنگین را کنترل کنید
Wildcard ابتدای عبارت، Regular Expression پیچیده و Aggregation روی Fieldهای نامناسب میتوانند منابع زیادی مصرف کنند.
برای Deep Pagination از search_after استفاده کنید
from و size برای صفحههای ابتدایی مناسباند، اما برای پیمایش عمیق بهتر است search_after به کار رود.
Backup داشته باشید
Snapshotهای دورهای تهیه و فرآیند Restore را آزمایش کنید. Backup آزمایشنشده تضمین نمیکند که بازیابی در زمان نیاز موفق خواهد بود.
مانیتورینگ را جدی بگیرید
شاخصهای مهم شامل این موارد هستند:
- وضعیت Cluster
- Heap و Garbage Collection
- فضای Disk
- زمان Search
- نرخ Indexing
- تعداد Queryهای ناموفق
- Thread Pool Rejection
- تعداد Shard
- زمان Merge
- Cache Hit Rate
خطاهای رایج و راهحل آنها
خطای Connection refused
علتهای احتمالی:
- Container اجرا نشده است.
- پورت ۹۲۰۰ در دسترس نیست.
- Elasticsearch هنوز آماده نشده است.
- برنامه داخل Container از
localhostاشتباه استفاده میکند.
بررسی کنید:
docker compose ps
docker compose logs elasticsearch
curl http://localhost:9200
اگر FastAPI و Elasticsearch هر دو داخل Docker Compose باشند، آدرس برنامه باید معمولاً نام Service باشد:
http://elasticsearch:9200
خطای mapper_parsing_exception
این خطا معمولاً وقتی رخ میدهد که مقدار واردشده با Mapping سازگار نیست؛ مثلاً یک رشته را در Field عددی ذخیره کردهاید.
Mapping را ببینید:
curl "http://localhost:9200/products/_mapping?pretty"
جستوجو نتیجهای برنمیگرداند
موارد زیر را بررسی کنید:
- آیا Document واقعاً ایندکس شده است؟
- آیا روی Field نوع
textازtermاستفاده کردهاید؟ - آیا نام Field درست است؟
- آیا فیلترها بیش از حد محدودکنندهاند؟
- آیا Refresh انجام شده است؟
- Analyzer زمان Index و Search سازگار است؟
برای مشاهده Tokenها:
curl -X POST "http://localhost:9200/_analyze?pretty" \
-H "Content-Type: application/json" \
-d '{
"analyzer": "persian",
"text": "آموزش برنامهنویسی فارسی"
}'
وضعیت Cluster زرد است
در Cluster تکگرهای، Replica نمیتواند روی همان Node اصلی قرار گیرد. برای محیط محلی تعداد Replica را صفر کنید:
curl -X PUT "http://localhost:9200/products/_settings" \
-H "Content-Type: application/json" \
-d '{
"number_of_replicas": 0
}'
خطای Result window is too large
این خطا معمولاً هنگام استفاده از from بزرگ رخ میدهد. از search_after استفاده کنید و صفحهبندی بسیار عمیق را در رابط کاربری محدود نگه دارید.
مصرف بالای حافظه
موارد زیر را بررسی کنید:
- تعداد Shard
- Aggregationهای پرهزینه
- Mapping و تعداد Fieldها
- Queryهای Wildcard و Regex
- اندازه Heap
- حجم Batch
- دریافت Documentهای بسیار بزرگ
- تعداد Queryهای همزمان
صرفاً افزایش حافظه همیشه ریشه مشکل را رفع نمیکند. ابتدا Query و ساختار Index را بررسی کنید.
Elasticsearch چه زمانی انتخاب مناسبی نیست؟
Elasticsearch برای همه مسائل مناسب نیست. در این شرایط بهتر است با احتیاط تصمیم بگیرید:
- فقط چند هزار رکورد و Search بسیار ساده دارید.
- پایگاه داده اصلی شما Full-Text Search کافی ارائه میدهد.
- تیم توان عملیاتی نگهداری Cluster جداگانه را ندارد.
- به تراکنشهای چندمرحلهای قوی نیاز دارید.
- دادهها باید بلافاصله و بدون تأخیر قابل مشاهده باشند.
- پروژه تحمل Eventual Consistency را ندارد.
- هزینه زیرساخت جدید از ارزش Search بیشتر است.
برای بسیاری از پروژههای کوچک، قابلیت Full-Text Search پایگاه داده میتواند نقطه شروع منطقی باشد. Elasticsearch زمانی ارزش بیشتری ایجاد میکند که کیفیت Ranking، تحلیل متن، مقیاس، Facet و انعطاف Query واقعاً اهمیت داشته باشد.
چکلیست راهاندازی
پیش از انتشار سرویس، این موارد را بررسی کنید:
- Mapping فیلدها مشخص و کنترلشده است.
- برای متن فارسی Analyzer مناسب انتخاب شده است.
- Fieldهای
textوkeywordبهدرستی تفکیک شدهاند. - Queryهای واقعی کاربران آزمایش شدهاند.
- فیلترها در بخش
filterقرار گرفتهاند. - وزن Fieldها با داده واقعی تنظیم شده است.
- Bulk Indexing برای ورود گروهی استفاده میشود.
- Indexها نسخهبندی شدهاند.
- برنامه از Alias پایدار استفاده میکند.
- Timeout و Retry محدود تنظیم شده است.
- صفحهبندی عمیق با
search_afterانجام میشود. - سلامت Cluster مانیتور میشود.
- Snapshot و Restore آزمایش شدهاند.
- کلید API در متغیر محیطی نگهداری میشود.
- پاسخ مدل هوش مصنوعی فقط بر داده بازیابیشده متکی است.
- مسیر جایگزین برای زمان عدم دسترسی مدل وجود دارد.
- هزینه و زمان تولید خلاصه هوش مصنوعی اندازهگیری میشود.
پرسشهای متداول
آیا Elasticsearch یک پایگاه داده است؟
Elasticsearch میتواند Documentهای JSON را ذخیره و بازیابی کند، اما معمولاً بهتر است آن را موتور Search و Analytics در نظر بگیرید. برای بسیاری از برنامهها PostgreSQL، MySQL یا MongoDB منبع اصلی داده باقی میماند.
آیا Elasticsearch برای زبان فارسی مناسب است؟
بله. Elasticsearch Analyzer داخلی فارسی دارد و میتوان Analyzer سفارشی نیز ساخت. بااینحال کیفیت نهایی به داده، Mapping، نرمالسازی و ارزیابی Queryهای واقعی بستگی دارد.
Elasticsearch بهتر است یا PostgreSQL Full-Text Search؟
برای پروژههای کوچک و Search ساده، PostgreSQL میتواند کافی باشد. Elasticsearch امکانات پیشرفتهتری برای Ranking، Analyzer، Highlight، Facet، Fuzzy Search و مقیاس توزیعشده ارائه میکند، اما نگهداری آن پیچیدهتر است.
آیا میتوان Elasticsearch را به FastAPI متصل کرد؟
بله. Client رسمی Python نسخه Async دارد و میتوان آن را با FastAPI استفاده کرد. اتصال باید در طول عمر برنامه مدیریت و هنگام خاموششدن سرویس بسته شود.
آیا Elasticsearch همان Vector Database است؟
Elasticsearch قابلیتهای جستوجوی برداری نیز دارد، اما کاربرد آن فقط Vector Search نیست. این ابزار در Full-Text Search، فیلتر، Aggregation و جستوجوی ترکیبی نیز استفاده میشود.
Query DSL چیست؟
Query DSL زبان JSONمحور Elasticsearch برای تعریف Search، Filter، Aggregation، Sort و سایر عملیات بازیابی است. Queryهایی مانند match، multi_match، bool، term و range بخشی از آن هستند.
چرا نتیجه تازه ثبتشده فوراً دیده نمیشود؟
Elasticsearch یک موتور Near Real-Time است. ممکن است میان ثبت Document و قابل جستوجوشدن آن فاصله کوتاهی وجود داشته باشد. برای عملیات خاص میتوان از refresh=wait_for استفاده کرد، اما نباید بدون ارزیابی آن را روی همه نوشتنها فعال کرد.
آیا اتصال Elasticsearch به هوش مصنوعی ضروری است؟
خیر. Elasticsearch بهتنهایی یک موتور جستوجوی کامل است. اتصال به مدل هوش مصنوعی زمانی مفید است که به خلاصهسازی، مقایسه یا پاسخ طبیعی براساس نتایج بازیابیشده نیاز دارید.
هزینه استفاده از درواره چگونه محاسبه میشود؟
هزینه به مدل انتخابشده و میزان ورودی و خروجی بستگی دارد. اطلاعات بهروز مدلها و قیمتها در صفحه مدلهای درواره منتشر میشود. در معماری پیشنهادی فقط هنگام درخواست خلاصه هوش مصنوعی، API مدل فراخوانی خواهد شد.
جمعبندی
Elasticsearch یک راهکار قدرتمند برای ساخت Search سریع، مرتبط و مقیاسپذیر است. مفاهیمی مانند Index، Document، Mapping، Analyzer و Query DSL پایههای اصلی کار با آن هستند. برای جستوجوی فارسی باید به نرمالسازی متن، انتخاب Analyzer، تفاوت text و keyword و ارزیابی Queryهای واقعی توجه ویژهای داشت.
در پروژه عملی این مقاله، یک موتور جستوجوی فارسی با Elasticsearch، Python و FastAPI ساختیم. API ایجادشده میتواند در چند Field جستوجو کند، نتایج را فیلتر و Highlight کند و در صورت درخواست کاربر، نتایج برتر را برای تولید خلاصه به API درواره بفرستد.
اگر میخواهید قابلیت جستوجوی برنامه خود را با پاسخهای طبیعی، خلاصهسازی یا مقایسه هوشمند تکمیل کنید، در درواره ثبتنام کنید، کلید API بگیرید و مدل مناسب پروژه را از صفحه مدلها انتخاب کنید.
منابع پیشنهادی
- مستندات رسمی Elasticsearch
- آموزش Search و Filter در Elasticsearch
- مستندات رسمی Elasticsearch Python Client
- مستندات FastAPI
- مستندات Docker Compose
مقالات مرتبط
- Hybrid Search چیست؟ ترکیب BM25 و Vector Search در RAG
- Semantic Search چیست؟ راهنمای جستوجوی معنایی
- Vector Database چیست؟ راهنمای پایگاه داده برداری
- آموزش ساخت RAG با LangChain، LlamaIndex و API درواره
- تحلیل لاگ با هوش مصنوعی؛ ساخت AI Log Analyzer عملی
- آموزش اتصال API هوش مصنوعی به اپلیکیشن
برای مطالعه شرایط استفاده و محدودیتهای مسئولیت، صفحه «سلب مسئولیت» را مشاهده کنید.