חיפוש פנים (Faceted Search) עם Elasticsearch ואגרגציות: מוני ספירה מדויקים
דמיינו קטלוג עם מיליון מוצרים. ללא חיפוש פנים, המשתמש דפדף ב-200 עמודים. עם זה, הוא מוצא את המותג וטווח המחירים הרצויים תוך שניות. אבל המונים משקרים אם לא מגדירים את האגרגציות POST /products/_search { "query": { "bool": { "must": [ { "match": { "title": "ноутбук" } } ], "filter": [ { "term": { "is_active": true } }, { "range": { "price": { "gte": 20000, "lte": 80000 } } } ] } }, "aggs": { "by_brand": { "terms": { "field": "brand", "size": 20, "order": { "_count": "desc" } } }, "price_ranges": { "range": { "field": "price", "ranges": [ { "key": "до 30 000", "to": 30000 }, { "key": "30–60 000", "from": 30000, "to": 60000 }, { "key": "60–100 000", "from": 60000, "to": 100000 }, { "key": "от 100 000", "from": 100000 } ] } }, "price_stats": { "stats": { "field": "price" } }, "by_category": { "terms": { "field": "category", "size": 10 } }, "by_rating": { "histogram": { "field": "rating", "interval": 1, "min_doc_count": 1 } } }, "size": 20, "from": 0 } ו-filter. מאמר זה מכסה כיצד לבנות מוני פנים נכונים על Elasticsearch, תוך הימנעות מטעויות אופייניות.
אנו משלבים את Elasticsearch עם חיפוש פנים בקטלוגים ובחנויות מסחר אלקטרוני. אלה מוני סינון: "מותג: Samsung (143), Apple (89)", "מחיר: עד 30,000 (234), 30–60,000 (145)". כאשר נבחר מסנן, רשימת המוצרים מצטמצמת, והמונים של ערכים אחרים מתעדכנים. במסדי נתונים רלציוניים פעולות כאלה יקרות — GROUP BY עם COUNT(*) על קבוצה מסוננת. ב-Elasticsearch, המשימה נפתרת עם אגרגציות, לעיתים קרובות בבקשה אחת. אנו נעריך את הפרויקט שלך וניישם אותו במפתח מלא תוך 2–4 ימי עבודה.
כיצד ליישם מוני פנים נכונים
בקשה אופיינית לקטלוג: שאילתת חיפוש + מסננים פעילים + אגרגציות לספירת פנים. דוגמה לבקשה בסיסית:
POST /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "ноутбук"
}
}
],
"filter": [
{
"term": {
"is_active": true
}
},
{
"range": {
"price": {
"gte": 20000,
"lte": 80000
}
}
}
]
}
},
"aggs": {
"by_brand": {
"terms": {
"field": "brand",
"size": 20,
"order": {
"_count": "desc"
}
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{
"key": "до 30 000",
"to": 30000
},
{
"key": "30–60 000",
"from": 30000,
"to": 60000
},
{
"key": "60–100 000",
"from": 60000,
"to": 100000
},
{
"key": "от 100 000",
"from": 100000
}
]
}
},
"price_stats": {
"stats": {
"field": "price"
}
},
"by_category": {
"terms": {
"field": "category",
"size": 10
}
},
"by_rating": {
"histogram": {
"field": "rating",
"interval": 1,
"min_doc_count": 1
}
}
},
"size": 20,
"from": 0
} הבעיה של מונים נעלמים בעת בחירת פן
אם המשתמש בוחר מותג "Samsung" ומוסיף אותו ל-by_brand, האגרגציה POST /products/_search { "query": { "bool": { "must": [ { "match": { "title": "ноутбук" } } ], "filter": [ { "term": { "is_active": true } } ] } }, "post_filter": { "bool": { "filter": [ { "term": { "brand": "Samsung" } } ] } }, "aggs": { "all_brands": { "global": {}, "aggs": { "filtered_brands": { "filter": { "bool": { "must": [ { "match": { "title": "ноутбук" } } ], "filter": [ { "term": { "is_active": true } } ] } }, "aggs": { "brands": { "terms": { "field": "brand", "size": 20 } } } } } }, "price_ranges": { "range": { "field": "price", "ranges": [ { "key": "до 30 000", "to": 30000 }, { "key": "30–60 000", "from": 30000, "to": 60000 }, { "key": "от 60 000", "from": 60000 } ] } } }, "size": 20 } סופרת רק בתוך הקבוצה המסוננת. המונים של מותגים אחרים הופכים לאפס — המשתמש לא יכול לראות כמה מוצרים יש ממותגים אחרים. הפתרון הוא Post Filter בשילוב עם Global Aggregation:
POST /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "ноутбук"
}
}
],
"filter": [
{
"term": {
"is_active": true
}
}
]
}
},
"post_filter": {
"bool": {
"filter": [
{
"term": {
"brand": "Samsung"
}
}
]
}
},
"aggs": {
"all_brands": {
"global": {},
"aggs": {
"filtered_brands": {
"filter": {
"bool": {
"must": [
{
"match": {
"title": "ноутбук"
}
}
],
"filter": [
{
"term": {
"is_active": true
}
}
]
}
},
"aggs": {
"brands": {
"terms": {
"field": "brand",
"size": 20
}
}
}
}
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{
"key": "до 30 000",
"to": 30000
},
{
"key": "30–60 000",
"from": 30000,
"to": 60000
},
{
"key": "от 60 000",
"from": 60000
}
]
}
}
},
"size": 20
}לפי תיעוד Elasticsearch, post_filter מוחל על התוצאות לאחר האגרגציה. האגרגציה global חורגת מההקשר של השאילתה — היא מאפשרת לספור אגרגטים על פני כל המסמכים עם מסננים נוספים.
כיצד לעבוד עם תכונות מקוננות?
אם תכונות (גודל, צבע, חומר) מאוחסנות כאובייקטים מסוג nested:
"mappings": {
"properties": {
"attributes": {
"type": "nested",
"properties": {
"name": {
"type": "keyword"
},
"value": {
"type": "keyword"
}
}
}
}
}אגרגציה על תכונות מקוננות:
"aggs": {
"attributes": {
"nested": {
"path": "attributes"
},
"aggs": {
"attribute_names": {
"terms": {
"field": "attributes.name",
"size": 50
},
"aggs": {
"attribute_values": {
"terms": {
"field": "attributes.value",
"size": 20
}
}
}
}
}
}
}זוהי אגרגציה מקוננת דו-רמתית: תחילה קיבוץ לפי שם התכונה ("צבע", "גודל"), בתוך כל קבוצה — ערכים ("שחור", "לבן").
Cardinality — ספירת ערכים ייחודיים
כדי להציג "נמצאו 1,247 מוצרים מ-89 מותגים", השתמשו באגרגציית cardinality עם השדה "mappings": { "properties": { "attributes": { "type": "nested", "properties": { "name": { "type": "keyword" }, "value": { "type": "keyword" } } } } } ו-"aggs": { "attributes": { "nested": { "path": "attributes" }, "aggs": { "attribute_names": { "terms": { "field": "attributes.name", "size": 50 }, "aggs": { "attribute_values": { "terms": { "field": "attributes.value", "size": 20 } } } } } } } 100. זה מבטיח דיוק עד 100 ערכים ייחודיים; מעל זה, מרווח השגיאה הוא 1–6%.
הגדרת חיפוש פנים ב-5 שלבים
- זיהוי שדות לסינון: מותג, מחיר, קטגוריה, דירוג, תכונות. קבוצה אופיינית היא 5–10 שדות.
- הגדרת mapping: השתמשו בסוג
brandלשדות מפתח,precision_thresholdלמובנים. ודאו ששדות לאגרגציות אינם מנותחים. - בניית שאילתה עם אגרגציות: כללו את כל האגרגציות הנדרשות (terms, range, histogram, nested). למונים נכונים, השתמשו באגרגציות
keywordו-nested. - החלת post_filter: הפרידו מסננים מהשאילתה הראשית. זה שומר על מוני האגרגציה.
- אופטימיזציית ביצועים: השתמשו ב-
post_filterלשדות עם קרדינליות נמוכה, caching לאגרגציות ללא שאילתת חיפוש, הגבילו גודל ל-20.
אופטימיזציית אגרגציות
אגרגציית global עם execution_hint: map גדול היא פעולה יקרה: כל shard מחזיר ערכי top-N, והצומת המרכזי ממזג תוצאות. עבור שדות עם קרדינליות גבוהה (אלפי ערכים ייחודיים), הביצועים מתדרדרים. שיטות אופטימיזציה:
טבלת שיטות אופטימיזציה
| שיטה | תיאור | מתי ליישם |
|---|---|---|
| execution_hint: map | לקרדינליות נמוכה (<1000) עובד מהר יותר מ-ordinals | שדות סטטוס, קטגוריה |
| אגרגציה caching | אגרגציות ללא שאילתת חיפוש נשמרות ב-shard request cache | עמודי קטלוג ללא חיפוש |
| הגבלת גודל | לפנים, ערכי top-20 מספיקים; אין צורך בניווט עמוק | תמיד |
השוואת סוגי אגרגציות
| סוג | דוגמת שימוש | ביצועים |
|---|---|---|
| Terms | קיבוץ לפי מותג | מהיר כאשר קרדינליות <10k |
| Range | טווחי מחירים | מהיר מאוד |
| Histogram | דירוג עם צעד 1 | מהיר |
| Nested | תכונות מקוננות | בינוני (תלוי במספר האובייקטים) |
| Cardinality | מותגים ייחודיים | מהיר (אלגוריתם HyperLogLog) |
יישום בצד השרת
מחלקת PHP לבניית שאילתות פנים:
class FacetedSearchService {
public function search(array $params): array {
$query = $this->buildQuery($params);
$response = $this->client->search([
'index' => 'products',
'body' => $query,
]);
return [
'hits' => $response['hits']['hits'],
'total' => $response['hits']['total']['value'],
'facets' => $this->extractFacets($response['aggregations']),
];
}
private function extractFacets(array $aggs): array {
$facets = [];
if (isset($aggs['by_brand'])) {
$facets['brand'] = array_map(fn($b) => [
'value' => $b['key'],
'count' => $b['doc_count'],
], $aggs['by_brand']['buckets']);
}
if (isset($aggs['price_ranges'])) {
$facets['price'] = array_map(fn($r) => [
'label' => $r['key'],
'count' => $r['doc_count'],
], $aggs['price_ranges']['buckets']);
}
return $facets;
}
} מה כלול בהגדרת חיפוש פנים במפתח מלא
- עיצוב סכמות mapping ואינדקסים.
- הגדרת כל סוגי האגרגציות (terms, range, nested, cardinality).
- יישום לוגיקת post_filter + global aggregations למונים נכונים.
- אופטימיזציית ביצועים (execution_hint, caching).
- כתיבת קוד בצד השרת לבניית שאילתות ועיבוד תשובות.
- תיעוד, הדרכת צוות, תמיכה לאחר היישום.
לוחות זמנים
- יישום בסיסי (3–5 סוגי אגרגציות) — 2 ימי עבודה.
- תרחיש מורכב (post_filter, global, nested) — 3–4 ימים.
- אופטימיזציה על נפחים אמיתיים (>5 מיליון מסמכים) — יום נוסף.
לצוות שלנו ניסיון של 6+ שנים עם Elasticsearch ומהנדסים מוסמכים. השלמנו מעל 50 פרויקטים של חיפוש פנים, ומבטיחים דיוק מונים וביצועים. צרו קשר לייעוץ — נעזור לכם להימנע מטעויות יקרות. העלות מחושבת באופן אישי, והחיסכון מהיישום יכול להיות משמעותי. קבלו פתרון מלא עם תיעוד מלא ותמיכה.







