본문 바로가기
WIKI 기술 지식 베이스

Implementing faceted search in OpenSearch

원문 보기 위키 갱신

출처: 문서

본문

OpenSearch에서 패싯 검색 구현하기

_패싯_은 사용자가 선택하여 검색 결과를 좁힐 수 있는 필터 가능한 필드예요. 전자상거래 맥락에서 브랜드, 색상, 크기, 가격 범위 같은 패싯을 검색 결과 왼쪽에서 볼 수 있어요. 예를 들어 '겨울 자켓' 같은 검색어는 많은 제품을 반환할 수 있어요. 패싯을 사용하면 사용자가 특정 색상이나 가격 범위로 제품을 필터링할 수 있어요.

패싯 검색은 각 패싯에 대한 값이나 범위 개수를 표시하여 사용자가 결과 분포를 이해하고 빠르게 필터를 적용할 수 있도록 도와줘요. 이 접근 방식은 전자상거래 및 위치 기반 검색에서 특히 유용해요. 정확한 값(색상이나 크기 등)에는 terms 집계를, 연속 값(가격, 날짜, 거리 등)에는 range 집계를 사용하여 패싯을 구현할 수 있어요.

이 튜토리얼에서는 전자상거래 웹사이트의 제품 카탈로그를 예시로 OpenSearch에서 패싯 검색을 구현하는 방법을 보여줄게요.

1단계: 인덱스 매핑 정의하기

먼저 패싯에 사용할 필드를 정의하세요. 매핑 구성은 효과적인 패싯 검색에 중요해요. 문자열 필드에 패싯을 적용하려면 text 필드는 집계에 최적화되지 않았으므로 keyword로 매핑하세요.

text 필드에 "fielddata": true를 설정하여 집계를 활성화할 수 있지만, 이 방법은 프로덕션에서 피해야 해요. 모든 필드 값을 힙 메모리에 로드하여 메모리 사용량이 크게 증가하고 성능 문제나 메모리 부족 오류를 일으킬 수 있기 때문이에요. {: .tip}

패싯 검색에서 흔한 문제는 데이터의 대소문자가 일관되지 않은 경우예요. 예를 들어 색상이 'red', 'RED', 'Red'로 저장되면 별도의 패싯 버킷이 생성되어 결과가 분산돼요.

이 문제를 해결하려면 인덱싱 중에 값을 소문자로 정규화하는 인제스트 파이프라인을 생성하세요:

PUT _ingest/pipeline/normalize-color-pipeline
{
  "description": "Normalize color field to lowercase",
  "processors": [
    {
      "lowercase": {
        "field": "color"
      }
    }
  ]
}

{% include copy-curl.html %}

그런 다음 모든 필드를 집계를 위해 keyword로 매핑하고 파이프라인을 인덱스에 적용하세요:

PUT /products
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "description": {
        "type": "text"
      },
      "color": {
        "type": "keyword"
      },
      "size": {
        "type": "keyword"
      },
      "price": {
        "type": "float"
      }
    }
  },
  "settings": {
    "default_pipeline": "normalize-color-pipeline"
  }
}

{% include copy-curl.html %}

2단계: 제품 데이터 인덱싱하기

다음으로, 인덱스에 샘플 데이터를 인덱싱하세요:

POST /products/_bulk
{ "index": {"_id": 1} }
{ "name": "Cotton T-shirt", "description": "Comfortable t-shirt for everyday wear", "color": "red", "size": "M", "price": 19.99 }
{ "index": {"_id": 2} }
{ "name": "T-shirt", "description": "Soft cotton t-shirt perfect for casual outings", "color": "Blue", "size": "L", "price": 19.99 }
{ "index": {"_id": 3} }
{ "name": "Jeans", "description": "Classic denim jeans with a modern fit", "color": "blue", "size": "M", "price": 49.99 }
{ "index": {"_id": 4} }
{ "name": "Sweater", "description": "Warm wool sweater for cold weather", "color": "RED", "size": "L", "price": 39.99 }

{% include copy-curl.html %}

3단계: 패싯 검색 실행하기

terms 집계를 사용하여 원하는 필드(이 예시에서는 color와 size)에 대한 패싯 버킷을 반환하세요:

POST /products/_search
{
  "query": {
    "match": {
      "name": "T-shirt"
    }
  },
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    },
    "sizes": {
      "terms": {
        "field": "size"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 색상과 크기별로 집계된 모든 티셔츠가 포함돼요:

응답 {: .text-delta}
{
  "took": 68,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0.59534115,
    "hits": [
      {
        "_index": "products",
        "_id": "2",
        "_score": 0.59534115,
        "_source": {
          "color": "blue",
          "size": "L",
          "price": 19.99,
          "name": "T-shirt",
          "description": "Soft cotton t-shirt perfect for casual outings"
        }
      },
      {
        "_index": "products",
        "_id": "1",
        "_score": 0.48764127,
        "_source": {
          "color": "red",
          "size": "M",
          "price": 19.99,
          "name": "Cotton T-shirt",
          "description": "Comfortable t-shirt for everyday wear"
        }
      }
    ]
  },
  "aggregations": {
    "sizes": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "L",
          "doc_count": 1
        },
        {
          "key": "M",
          "doc_count": 1
        }
      ]
    },
    "colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "blue",
          "doc_count": 1
        },
        {
          "key": "red",
          "doc_count": 1
        }
      ]
    }
  }
}

여러 필드 검색하기

name과 description 필드 모두에서 검색하려면 multi_match 쿼리를 사용하세요. 쿼리는 여러 텍스트 필드(name, description)를 검색하지만, 집계는 일관된 패싯 값을 위해 키워드 필드(color, size)를 사용한다는 점에 유의하세요:

POST /products/_search
{
  "query": {
    "multi_match": {
      "query": "cotton t-shirt",
      "fields": ["name", "description"],
      "operator": "and",
      "type": "best_fields"
    }
  },
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    },
    "sizes": {
      "terms": {
        "field": "size"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 두 티셔츠가 모두 포함돼요:

응답 {: .text-delta}
{
  "took": 33,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 1.0944791,
    "hits": [
      {
        "_index": "products",
        "_id": "2",
        "_score": 1.0944791,
        "_source": {
          "color": "blue",
          "size": "L",
          "price": 19.99,
          "name": "T-shirt",
          "description": "Soft cotton t-shirt perfect for casual outings"
        }
      },
      {
        "_index": "products",
        "_id": "1",
        "_score": 0.9111493,
        "_source": {
          "color": "red",
          "size": "M",
          "price": 19.99,
          "name": "Cotton T-shirt",
          "description": "Comfortable t-shirt for everyday wear"
        }
      }
    ]
  },
  "aggregations": {
    "sizes": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "L",
          "doc_count": 1
        },
        {
          "key": "M",
          "doc_count": 1
        }
      ]
    },
    "colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "blue",
          "doc_count": 1
        },
        {
          "key": "red",
          "doc_count": 1
        }
      ]
    }
  }
}

multi_match 쿼리 매개변수에 대한 자세한 내용은 Multi-match query를 참조하세요.

패싯 데이터만 반환하기

실제 검색 결과 없이 패싯 개수만 필요한 경우 "size": 0으로 설정하여 성능을 개선하세요:

POST /products/_search
{
  "size": 0,
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    }
  }
}

{% include copy-curl.html %}

결과 개수 지정하기

기본적으로 terms 집계는 가장 빈도가 높은 상위 10개 용어를 반환해요. 더 많거나 적은 결과가 필요하면 size 매개변수를 설정할 수 있어요:

POST /products/_search
{
  "aggs": {
    "colors": {
      "terms": {
        "field": "color",
        "size": 20
      }
    }
  }
}

{% include copy-curl.html %}

4단계: 패싯 값으로 필터링하기

결과를 좁히려면(예: 파란색이고 L 사이즈인 티셔츠 검색) filter 절을 추가하세요:

POST /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "T-shirt" } }
      ],
      "filter": [
        { "term": { "color": "blue" } },
        { "term": { "size": "L" } }
      ]
    }
  },
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    },
    "sizes": {
      "terms": {
        "field": "size"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 일치하는 제품이 포함돼요:

응답 {: .text-delta}
{
  "took": 66,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0.59534115,
    "hits": [
      {
        "_index": "products",
        "_id": "2",
        "_score": 0.59534115,
        "_source": {
          "color": "blue",
          "size": "L",
          "price": 19.99,
          "name": "T-shirt",
          "description": "Soft cotton t-shirt perfect for casual outings"
        }
      }
    ]
  },
  "aggregations": {
    "sizes": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "L",
          "doc_count": 1
        }
      ]
    },
    "colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "blue",
          "doc_count": 1
        }
      ]
    }
  }
}

결과는 다음과 같이 나타나요.

패싯 검색 결과

필터링 중 패싯 옵션 유지하기

사용자가 패싯 필터를 선택할 때 일반적으로 다른 사용 가능한 필터링 옵션도 계속 볼 수 있기를 기대해요. 예를 들어 사용자가 빨간색 티셔츠로 필터링하면 색상 패싯은 원래 검색 결과에서 '빨간색'뿐만 아니라 모든 사용 가능한 색상(빨간색, 파란색 등)을 계속 표시해야 해요. 이렇게 하면 사용자가 전체 옵션 범위를 이해하고 필터 간에 쉽게 전환할 수 있어요.

이 동작은 post_filter를 사용하여 구현할 수 있어요. post_filter는 집계가 계산된 후에 검색 결과를 필터링하므로 패싯은 필터링되지 않은 데이터셋을 반영해요:

POST /products/_search
{
  "query": {
    "match": {
      "name": "t-shirt"
    }
  },
  "post_filter": {
    "term": { "color": "red" }
  },
  "aggs": {
    "all_colors": {
      "terms": {
        "field": "color"
      }
    },
    "sizes_for_red": {
      "filter": {
        "term": { "color": "red" }
      },
      "aggs": {
        "sizes": {
          "terms": {
            "field": "size"
          }
        }
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 일치하는 티셔츠와 모든 티셔츠의 색상 버킷이 포함돼요:

응답 {: .text-delta}
{
  "took": 17,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0.48764127,
    "hits": [
      {
        "_index": "products",
        "_id": "1",
        "_score": 0.48764127,
        "_source": {
          "color": "red",
          "size": "M",
          "price": 19.99,
          "name": "Cotton T-shirt",
          "description": "Comfortable t-shirt for everyday wear"
        }
      }
    ]
  },
  "aggregations": {
    "sizes_for_red": {
      "doc_count": 1,
      "sizes": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
          {
            "key": "M",
            "doc_count": 1
          }
        ]
      }
    },
    "all_colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "blue",
          "doc_count": 1
        },
        {
          "key": "red",
          "doc_count": 1
        }
      ]
    }
  }
}

결과는 다음과 같이 나타나요.

필터를 유지하는 패싯 검색 결과

또는 global 집계를 사용하여 적용된 필터와 관계없이 패싯이 항상 전체 데이터셋을 반영하도록 할 수 있어요:

POST /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "t-shirt" } }
      ],
      "filter": [
        { "term": { "color": "red" } }
      ]
    }
  },
  "aggs": {
    "all_facets": {
      "global": {},
      "aggs": {
        "all_colors": {
          "filter": {
            "match": { "name": "t-shirt" }
          },
          "aggs": {
            "colors": {
              "terms": {
                "field": "color"
              }
            }
          }
        }
      }
    },
    "filtered_sizes": {
      "terms": {
        "field": "size"
      }
    }
  }
}

{% include copy-curl.html %}

global 집계는 전체 인덱스에 대해 실행되므로 올바른 패싯 개수를 얻으려면 global 집계 내에서 기본 쿼리(티셔츠 검색)를 다시 적용해야 해요. 응답은 앞선 접근 방식과 유사해요:

응답 {: .text-delta}
{
  "took": 9,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 1,
      "relation": "eq"
    },
    "max_score": 0.48764127,
    "hits": [
      {
        "_index": "products",
        "_id": "1",
        "_score": 0.48764127,
        "_source": {
          "color": "red",
          "size": "M",
          "price": 19.99,
          "name": "Cotton T-shirt",
          "description": "Comfortable t-shirt for everyday wear"
        }
      }
    ]
  },
  "aggregations": {
    "filtered_sizes": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "M",
          "doc_count": 1
        }
      ]
    },
    "all_facets": {
      "doc_count": 4,
      "all_colors": {
        "doc_count": 2,
        "colors": {
          "doc_count_error_upper_bound": 0,
          "sum_other_doc_count": 0,
          "buckets": [
            {
              "key": "blue",
              "doc_count": 1
            },
            {
              "key": "red",
              "doc_count": 1
            }
          ]
        }
      }
    }
  }
}

패싯 값 제외하기

특정 패싯 값으로 필터링하는 것 외에도 사용자는 결과에서 특정 값을 제외하고 싶을 수 있어요. 예를 들어 사용자는 빨간색 제품을 제외한 모든 제품을 보고 싶을 수 있어요. must_not 절을 사용하여 특정 패싯 값을 제외하세요:

POST /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match_all": {} }
      ],
      "must_not": [
        { "term": { "color": "red" } }
      ]
    }
  },
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    },
    "sizes": {
      "terms": {
        "field": "size"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 빨간색이 아닌 제품과 색상 및 크기 버킷이 포함돼요:

응답 {: .text-delta}
{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products",
        "_id": "2",
        "_score": 1,
        "_source": {
          "color": "blue",
          "size": "L",
          "price": 19.99,
          "name": "T-shirt",
          "description": "Soft cotton t-shirt perfect for casual outings"
        }
      },
      {
        "_index": "products",
        "_id": "3",
        "_score": 1,
        "_source": {
          "color": "blue",
          "size": "M",
          "price": 49.99,
          "name": "Jeans",
          "description": "Classic denim jeans with a modern fit"
        }
      }
    ]
  },
  "aggregations": {
    "sizes": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "L",
          "doc_count": 1
        },
        {
          "key": "M",
          "doc_count": 1
        }
      ]
    },
    "colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "blue",
          "doc_count": 2
        }
      ]
    }
  }
}

결과는 다음과 같이 나타나요.

패싯 검색 결과 제외 필터링

5단계: 범위 패싯

가격, 평점, 날짜와 같은 숫자 값을 포함하는 필드에 대해 범위 패싯을 만들 수 있어요.

숫자 범위

제품의 가격 범위를 만들려면 range 집계를 사용하세요:

POST /products/_search
{
  "aggs": {
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 20, "key": "Under $20" },
          { "from": 20, "to": 40, "key": "$20 - $40" },
          { "from": 40, "key": "Over $40" }
        ]
      }
    }
  }
}

{% include copy-curl.html %}

응답은 제품을 가격별로 버킷화해요:

응답 {: .text-delta}
{
  "took": 76,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products",
        "_id": "1",
        "_score": 1,
        "_source": {
          "color": "red",
          "size": "M",
          "price": 19.99,
          "name": "Cotton T-shirt",
          "description": "Comfortable t-shirt for everyday wear"
        }
      },
      {
        "_index": "products",
        "_id": "2",
        "_score": 1,
        "_source": {
          "color": "blue",
          "size": "L",
          "price": 19.99,
          "name": "T-shirt",
          "description": "Soft cotton t-shirt perfect for casual outings"
        }
      },
      {
        "_index": "products",
        "_id": "3",
        "_score": 1,
        "_source": {
          "color": "blue",
          "size": "M",
          "price": 49.99,
          "name": "Jeans",
          "description": "Classic denim jeans with a modern fit"
        }
      },
      {
        "_index": "products",
        "_id": "4",
        "_score": 1,
        "_source": {
          "color": "red",
          "size": "L",
          "price": 39.99,
          "name": "Sweater",
          "description": "Warm wool sweater for cold weather"
        }
      }
    ]
  },
  "aggregations": {
    "price_ranges": {
      "buckets": [
        {
          "key": "Under $20",
          "to": 20,
          "doc_count": 2
        },
        {
          "key": "$20 - $40",
          "from": 20,
          "to": 40,
          "doc_count": 1
        },
        {
          "key": "Over $40",
          "from": 40,
          "doc_count": 1
        }
      ]
    }
  }
}

지리적 범위

지리적 범위는 사용자 위치에서 특정 거리 내에 있는 매장 찾기와 같은 위치 기반 패싯에 유용해요. 먼저 geo_point 유형의 store_location 필드를 포함하는 매핑을 생성하세요:

PUT /stores
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text"
      },
      "store_location": {
        "type": "geo_point"
      }
    }
  }
}

{% include copy-curl.html %}

서로 다른 매장이 포함된 문서를 인덱스에 인덱싱하세요:

POST /stores/_bulk
{ "index": { "_id": "1" } }
{ "name": "Downtown Store", "store_location": { "lat": 40.7510, "lon": -73.9900 } }
{ "index": { "_id": "2" } }
{ "name": "Suburban Store", "store_location": { "lat": 40.8300, "lon": -74.2000 } }
{ "index": { "_id": "3" } }
{ "name": "Outskirts Store", "store_location": { "lat": 41.2000, "lon": -74.8000 } }

{% include copy-curl.html %}

위치 기반 패싯(예: 가까운 매장에서 구매 가능한 제품 찾기)의 경우 geo_distance 집계를 사용하세요:

POST /stores/_search
{
  "aggs": {
    "store_distance": {
      "geo_distance": {
        "field": "store_location",
        "origin": "40.7507, -73.9895",
        "unit": "mi",
        "ranges": [
          { "to": 5, "key": "Within 5 miles" },
          { "from": 5, "to": 25, "key": "5-25 miles away" },
          { "from": 25, "key": "Over 25 miles away" }
        ]
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 거리별로 버킷화된 세 매장이 모두 포함돼요:

응답 {: .text-delta}
{
  "took": 16,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "stores",
        "_id": "1",
        "_score": 1,
        "_source": {
          "name": "Downtown Store",
          "store_location": {
            "lat": 40.751,
            "lon": -73.99
          }
        }
      },
      {
        "_index": "stores",
        "_id": "2",
        "_score": 1,
        "_source": {
          "name": "Suburban Store",
          "store_location": {
            "lat": 40.83,
            "lon": -74.2
          }
        }
      },
      {
        "_index": "stores",
        "_id": "3",
        "_score": 1,
        "_source": {
          "name": "Outskirts Store",
          "store_location": {
            "lat": 41.2,
            "lon": -74.8
          }
        }
      }
    ]
  },
  "aggregations": {
    "store_distance": {
      "buckets": [
        {
          "key": "Within 5 miles",
          "from": 0,
          "to": 5,
          "doc_count": 1
        },
        {
          "key": "5-25 miles away",
          "from": 5,
          "to": 25,
          "doc_count": 1
        },
        {
          "key": "Over 25 miles away",
          "from": 25,
          "doc_count": 1
        }
      ]
    }
  }
}

계층적 패싯

계층적 패싯은 카테고리 > 하위 카테고리 > 제품 유형과 같은 속성 계층 구조를 통해 드릴다운할 수 있게 해줘요. 이는 구분자로 계층 구조를 인코딩하는 필드를 사용하여 구현할 수 있어요.

먼저 path_hierarchy 토크나이저를 사용하여 모든 계층 수준을 자동으로 생성하는 인제스트 파이프라인을 생성하세요:

PUT _ingest/pipeline/category-hierarchy-pipeline
{
  "description": "Split category path into multiple hierarchy fields",
  "processors": [
    {
      "script": {
        "lang": "painless",
        "source": """
          // Split the category_path on '>'
          def parts = ctx.category_path.splitOnToken('>');
          
          // Create individual-level fields
          if (parts.length >= 1) {
            ctx.category_level1 = parts[0].trim();
          }
          if (parts.length >= 2) {
            ctx.category_level2 = parts[1].trim();
          }
          if (parts.length >= 3) {
            ctx.category_level3 = parts[2].trim();
          }
          
          // Create hierarchy array with cumulative paths
          def hierarchy = [];
          def currentPath = '';
          for (int i = 0; i < parts.length; i++) {
            if (i == 0) {
              currentPath = parts[i].trim();
            } else {
              currentPath = currentPath + '>' + parts[i].trim();
            }
            hierarchy.add(currentPath);
          }
          ctx.category_hierarchy = hierarchy;
        """
      }
    }
  ]
}

{% include copy.html %}

터미널에서 명령을 실행하는 경우 해당 cURL 요청을 사용하세요:

curl -XPUT "http://localhost:9200/_ingest/pipeline/category-hierarchy-pipeline" -H 'Content-Type: application/json' -d'
{
  "description": "Split category path into multiple hierarchy fields",
  "processors": [
    {
      "script": {
        "lang": "painless",
        "source": "\n          // Split the category_path on '\''>'\''\n          def parts = ctx.category_path.splitOnToken('\''>'\'');\n          \n          // Create individual level fields\n          if (parts.length >= 1) {\n            ctx.category_level1 = parts[0].trim();\n          }\n          if (parts.length >= 2) {\n            ctx.category_level2 = parts[1].trim();\n          }\n          if (parts.length >= 3) {\n            ctx.category_level3 = parts[2].trim();\n          }\n          \n          // Create hierarchy array with cumulative paths\n          def hierarchy = [];\n          def currentPath = '\'''\'';\n          for (int i = 0; i < parts.length; i++) {\n            if (i == 0) {\n              currentPath = parts[i].trim();\n            } else {\n              currentPath = currentPath + '\''>'\'' + parts[i].trim();\n            }\n            hierarchy.add(currentPath);\n          }\n          ctx.category_hierarchy = hierarchy;\n        "
      }
    }
  ]
}'

{% include copy.html %}

이 접근 방식은 각 계층 수준을 별도의 keyword 필드로 명시적으로 저장하여 text 필드에 fielddata를 사용하는 것을 피해요. 이는 더 많은 저장 공간을 필요로 하지만 더 나은 쿼리 성능을 제공하고 집계에 더 메모리 효율적이에요. 프로덕션 시스템에서는 인제스트 파이프라인이나 애플리케이션 로직을 사용하여 인덱싱 중에 계층 수준 추출을 자동화할 수 있어요.

다음으로, 계층적 필드로 인덱스 매핑을 정의하고 파이프라인을 인덱스의 기본 파이프라인으로 설정하세요:

PUT /products-advanced
{
  "mappings": {
    "properties": {
      "name": { "type": "text" },
      "color": { "type": "keyword" },
      "category_path": { "type": "keyword" },
      "category_level1": { "type": "keyword" },
      "category_level2": { "type": "keyword" },
      "category_level3": { "type": "keyword" },
      "category_hierarchy": { "type": "keyword" }
    }
  },
  "settings": {
    "default_pipeline": "category-hierarchy-pipeline"
  }
}

{% include copy-curl.html %}

인제스트 파이프라인을 사용하여 계층적 데이터로 제품을 인덱싱하여 계층 수준을 자동으로 생성하세요:

POST /products-advanced/_bulk
{ "index": {"_id": 1} }
{ "name": "Cotton T-Shirt", "color": "red", "category_path": "Clothing>Shirts>T-Shirts" }
{ "index": {"_id": 2} }
{ "name": "Wool Sweater", "color": "red", "category_path": "Clothing>Sweaters>Wool" }
{ "index": {"_id": 3} }
{ "name": "Running Shoes", "color": "red", "category_path": "Footwear>Athletic>Running" }
{ "index": {"_id": 4} }
{ "name": "Dress Shirt", "color": "blue", "category_path": "Clothing>Shirts>Dress" }
{ "index": {"_id": 5} }
{ "name": "Hiking Boots", "color": "blue", "category_path": "Footwear>Outdoor>Hiking" }
{ "index": {"_id": 6} }
{ "name": "Casual Sneakers", "color": "blue", "category_path": "Footwear>Casual>Sneakers" }

{% include copy-curl.html %}

여러 집계를 사용하여 계층적 패싯을 쿼리하면 카테고리 데이터의 다양한 보기를 얻을 수 있어요. 개별 계층 수준에서 집계하거나 단일 결과에서 전체 계층 경로를 볼 수 있어요:

POST /products-advanced/_search
{
  "aggs": {
    "top_categories": {
      "terms": {
        "field": "category_level1"
      }
    },
    "subcategories": {
      "terms": {
        "field": "category_level2"
      }
    },
    "full_hierarchy": {
      "terms": {
        "field": "category_hierarchy"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 별도의 집계 결과가 있는 평면 구조가 포함돼요. 카테고리는 교차 카테고리 합계와 함께 개수를 표시해요. 예를 들어 Athletic 하위 카테고리는 Clothing(1)과 Footwear(1)의 결합된 개수를 보여줘요:

응답 {: .text-delta}
{
  "took": 116,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products-advanced",
        "_id": "1",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>T-Shirts"
          ],
          "category_level2": "Shirts",
          "color": "red",
          "category_path": "Clothing>Shirts>T-Shirts",
          "name": "Cotton T-Shirt",
          "category_level3": "T-Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "2",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Athletic",
            "Clothing>Athletic>Shirts"
          ],
          "category_level2": "Athletic",
          "color": "red",
          "category_path": "Clothing>Athletic>Shirts",
          "name": "Athletic shirt",
          "category_level3": "Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "3",
        "_score": 1,
        "_source": {
          "category_level1": "Footwear",
          "category_hierarchy": [
            "Footwear",
            "Footwear>Athletic",
            "Footwear>Athletic>Running"
          ],
          "category_level2": "Athletic",
          "color": "red",
          "category_path": "Footwear>Athletic>Running",
          "name": "Running Shoes",
          "category_level3": "Running"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "4",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "blue",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "5",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "white",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "6",
        "_score": 1,
        "_source": {
          "category_level1": "Footwear",
          "category_hierarchy": [
            "Footwear",
            "Footwear>Casual",
            "Footwear>Casual>Sneakers"
          ],
          "category_level2": "Casual",
          "color": "blue",
          "category_path": "Footwear>Casual>Sneakers",
          "name": "Casual Sneakers",
          "category_level3": "Sneakers"
        }
      }
    ]
  },
  "aggregations": {
    "full_hierarchy": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Clothing",
          "doc_count": 4
        },
        {
          "key": "Clothing>Shirts",
          "doc_count": 3
        },
        {
          "key": "Clothing>Shirts>Dress",
          "doc_count": 2
        },
        {
          "key": "Footwear",
          "doc_count": 2
        },
        {
          "key": "Clothing>Athletic",
          "doc_count": 1
        },
        {
          "key": "Clothing>Athletic>Shirts",
          "doc_count": 1
        },
        {
          "key": "Clothing>Shirts>T-Shirts",
          "doc_count": 1
        },
        {
          "key": "Footwear>Athletic",
          "doc_count": 1
        },
        {
          "key": "Footwear>Athletic>Running",
          "doc_count": 1
        },
        {
          "key": "Footwear>Casual",
          "doc_count": 1
        },
        {
          "key": "Footwear>Casual>Sneakers",
          "doc_count": 1
        }
      ]
    },
    "top_categories": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Clothing",
          "doc_count": 4
        },
        {
          "key": "Footwear",
          "doc_count": 2
        }
      ]
    },
    "subcategories": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Shirts",
          "doc_count": 3
        },
        {
          "key": "Athletic",
          "doc_count": 2
        },
        {
          "key": "Casual",
          "doc_count": 1
        }
      ]
    }
  }
}

결과는 다음과 같이 나타나요.

계층적 평면 카테고리가 있는 패싯 검색 결과

계층적 탐색을 위해 중첩 집계를 사용하세요:

POST /products-advanced/_search
{
  "aggs": {
    "top_categories": {
      "terms": {
        "field": "category_level1"
      },
      "aggs": {
        "subcategories": {
          "terms": {
            "field": "category_level2"
          }
        }
      }
    },
    "full_hierarchy": {
      "terms": {
        "field": "category_hierarchy"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 상위 카테고리 아래에 중첩된 하위 카테고리가 있는 계층적 구조가 있어요. 예를 들어 Athletic은 Clothing(1)과 Footwear(1) 아래 모두에 나타나요. 하위 카테고리 개수는 상위 카테고리로 범위가 지정돼요:

응답 {: .text-delta}
{
  "took": 79,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 6,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products-advanced",
        "_id": "1",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>T-Shirts"
          ],
          "category_level2": "Shirts",
          "color": "red",
          "category_path": "Clothing>Shirts>T-Shirts",
          "name": "Cotton T-Shirt",
          "category_level3": "T-Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "2",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Athletic",
            "Clothing>Athletic>Shirts"
          ],
          "category_level2": "Athletic",
          "color": "red",
          "category_path": "Clothing>Athletic>Shirts",
          "name": "Athletic shirt",
          "category_level3": "Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "3",
        "_score": 1,
        "_source": {
          "category_level1": "Footwear",
          "category_hierarchy": [
            "Footwear",
            "Footwear>Athletic",
            "Footwear>Athletic>Running"
          ],
          "category_level2": "Athletic",
          "color": "red",
          "category_path": "Footwear>Athletic>Running",
          "name": "Running Shoes",
          "category_level3": "Running"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "4",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "blue",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "5",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "white",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "6",
        "_score": 1,
        "_source": {
          "category_level1": "Footwear",
          "category_hierarchy": [
            "Footwear",
            "Footwear>Casual",
            "Footwear>Casual>Sneakers"
          ],
          "category_level2": "Casual",
          "color": "blue",
          "category_path": "Footwear>Casual>Sneakers",
          "name": "Casual Sneakers",
          "category_level3": "Sneakers"
        }
      }
    ]
  },
  "aggregations": {
    "full_hierarchy": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Clothing",
          "doc_count": 4
        },
        {
          "key": "Clothing>Shirts",
          "doc_count": 3
        },
        {
          "key": "Clothing>Shirts>Dress",
          "doc_count": 2
        },
        {
          "key": "Footwear",
          "doc_count": 2
        },
        {
          "key": "Clothing>Athletic",
          "doc_count": 1
        },
        {
          "key": "Clothing>Athletic>Shirts",
          "doc_count": 1
        },
        {
          "key": "Clothing>Shirts>T-Shirts",
          "doc_count": 1
        },
        {
          "key": "Footwear>Athletic",
          "doc_count": 1
        },
        {
          "key": "Footwear>Athletic>Running",
          "doc_count": 1
        },
        {
          "key": "Footwear>Casual",
          "doc_count": 1
        },
        {
          "key": "Footwear>Casual>Sneakers",
          "doc_count": 1
        }
      ]
    },
    "top_categories": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "Clothing",
          "doc_count": 4,
          "subcategories": {
            "doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": [
              {
                "key": "Shirts",
                "doc_count": 3
              },
              {
                "key": "Athletic",
                "doc_count": 1
              }
            ]
          }
        },
        {
          "key": "Footwear",
          "doc_count": 2,
          "subcategories": {
            "doc_count_error_upper_bound": 0,
            "sum_other_doc_count": 0,
            "buckets": [
              {
                "key": "Athletic",
                "doc_count": 1
              },
              {
                "key": "Casual",
                "doc_count": 1
              }
            ]
          }
        }
      ]
    }
  }
}

결과는 다음과 같이 나타나요.

계층적 중첩 카테고리가 있는 패싯 검색 결과

접두사 쿼리를 사용하면 카테고리 계층 구조의 특정 브랜치에 대한 필터링이 가능하여 결과를 특정 카테고리 수준과 모든 하위 카테고리로 범위를 지정할 수 있어요. Clothing>Shirts 카테고리와 그 하위 카테고리에만 포함된 제품을 표시하려면 정확한 접두사 일치를 위해 keyword 필드를 사용하세요:

POST /products-advanced/_search
{
  "query": {
    "prefix": {
      "category_path": "Clothing>Shirts"
    }
  }
}

{% include copy-curl.html %}

응답에는 일치하는 문서가 포함돼요. 운동용 셔츠는 반환되지 않는다는 점에 유의하세요:

응답 {: .text-delta}
{
  "took": 44,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 3,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products-advanced",
        "_id": "1",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>T-Shirts"
          ],
          "category_level2": "Shirts",
          "color": "red",
          "category_path": "Clothing>Shirts>T-Shirts",
          "name": "Cotton T-Shirt",
          "category_level3": "T-Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "4",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "blue",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "5",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "white",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      }
    ]
  }
}

Clothing 카테고리에서만 색상별로 집계하려면 다음 요청을 사용하세요:

POST /products-advanced/_search
{
  "query": {
    "prefix": {
      "category_path": "Clothing>"
    }
  },
  "aggs": {
    "colors": {
      "terms": {
        "field": "color"
      }
    }
  }
}

{% include copy-curl.html %}

응답에는 Clothing 제품만 포함돼요:

응답 {: .text-delta}
{
  "took": 30,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "products-advanced",
        "_id": "1",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>T-Shirts"
          ],
          "category_level2": "Shirts",
          "color": "red",
          "category_path": "Clothing>Shirts>T-Shirts",
          "name": "Cotton T-Shirt",
          "category_level3": "T-Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "2",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Athletic",
            "Clothing>Athletic>Shirts"
          ],
          "category_level2": "Athletic",
          "color": "red",
          "category_path": "Clothing>Athletic>Shirts",
          "name": "Athletic shirt",
          "category_level3": "Shirts"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "4",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "blue",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      },
      {
        "_index": "products-advanced",
        "_id": "5",
        "_score": 1,
        "_source": {
          "category_level1": "Clothing",
          "category_hierarchy": [
            "Clothing",
            "Clothing>Shirts",
            "Clothing>Shirts>Dress"
          ],
          "category_level2": "Shirts",
          "color": "white",
          "category_path": "Clothing>Shirts>Dress",
          "name": "Dress Shirt",
          "category_level3": "Dress"
        }
      }
    ]
  },
  "aggregations": {
    "colors": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        {
          "key": "red",
          "doc_count": 2
        },
        {
          "key": "blue",
          "doc_count": 1
        },
        {
          "key": "white",
          "doc_count": 1
        }
      ]
    }
  }
}

마찬가지로 prefix 쿼리에 "category_path": "Clothing>Shirts"를 지정하여 Clothing>Shirts 카테고리를 집계할 수 있어요. 그러면 애플리케이션 코드에서 접두사를 제거하여 더 깔끔한 표시를 제공할 수 있어요(예: 필요한 경우 Clothing>Shirts를 Shirts로 변경). OpenSearch는 토큰화 및 집계와 같은 무거운 작업을 수행하고, 애플리케이션 코드는 계층 수준에 따라 접두사를 제거하여 표시 형식을 처리해요.

관련 문서

더 알아보기 (Learn more)