kNN 검색 (kNN Search)

k-최근접 이웃(k-nearest neighbor, kNN) 검색은 cosine이나 L2 norm 같은 유사도(similarity) 측정 기준을 이용해 쿼리 벡터에서 가장 가까운 k개의 벡터를 찾는 검색 방식이에요.

Elasticsearch의 kNN 검색을 쓰면 단순한 키워드 일치가 아니라 의미(semantic) 기반으로 결과를 가져올 수 있어요. kNN 벡터 유사도 검색의 대표적인 사용 사례로는 이런 것들이 있어요.

  • 검색(Search) — 의미 기반 텍스트 검색, 이미지·비디오 유사도 검색
  • 추천(Recommendations) — 상품 추천, 협업 필터링, 개인화 콘텐츠 발견
  • 분석(Analysis) — 이상 감지, 패턴 매칭

kNN 검색의 전제 조건 (Prerequisites)

Elasticsearch에서 kNN 검색을 실행하려면 몇 가지가 준비되어 있어야 해요.

  • 데이터가 벡터화되어 있어야 해요. Elasticsearch 안의 NLP 모델을 쓰거나 Elasticsearch 밖에서 벡터를 생성할 수 있어요. 밀집 벡터(dense vector)에는 dense_vector 필드 타입을 사용해요.
  • 쿼리 벡터는 문서 벡터와 같은 차원(dimension)이어야 하고, 같은 모델로 생성되어야 해요.
  • 이미 벡터를 가지고 있다면 Bring your own dense vectors 문서를 참고하세요.

인덱스를 만들려면 다음의 인덱스 권한이 필요해요.

  • dense_vector 필드를 가진 인덱스를 만들려면 create_index 또는 manage
  • 데이터를 추가하려면 create, index, 또는 write
  • 인덱스를 검색하려면 read

만약 Elastic Cloud Serverless를 쓴다면 kNN 검색을 구현하기 전에 Elasticsearch 프로젝트와 Vector Database 프로젝트를 비교해 보세요.

kNN 검색 방식

Elasticsearch는 kNN 검색을 수행하는 여러 방법을 제공해요. 어떤 방법을 쓸지는 필드 타입과, kNN을 다른 쿼리와 결합해야 하는지에 따라 달라져요.

근사 kNN (Approximate kNN)

빠르고 확장 가능한 유사도 검색이에요. 대부분의 프로덕션 워크로드에 적합합니다. 근사 kNN 검색을 실행하는 방법은 세 가지가 있고, 각각 지원하는 필드 타입이 달라요.

방법 (Method) 지원 필드 타입 사용 사례
최상위 knn 옵션 (Top-level knn option) dense_vector 단독 kNN 검색 또는 스코어 퓨전을 이용한 하이브리드 검색
knn 쿼리 dense_vector, semantic_text bool 절 안에서 다른 쿼리와 결합 가능. semantic_text 필드에는 필수
knn 리트리버 (retriever) dense_vector 랭킹과 결과 병합을 위한 리트리버 파이프라인 안에서 사용

만약 semantic_text 필드를 쓴다면 가장 간단하게는 match 쿼리로 검색하고, 검색을 더 세밀하게 제어해야 할 때는 knn 쿼리를 쓰면 돼요.

정확·전수 탐색 kNN (Exact, brute-force kNN)

벡터 함수가 들어 있는 script_score 쿼리를 사용해요. 소규모 데이터셋이나 정밀한 스코어링에 가장 좋아요. exact kNN을 참고하세요.

근사 kNN과 정확 kNN의 선택

근사 kNN은 낮은 지연 시간과 좋은 정확도를 제공하지만, 정확 kNN은 정확한 결과를 보장하는 대신 대규모 데이터셋에서는 확장이 잘 안 돼요. 정확 kNN에서는 script_score 쿼리가 벡터 함수를 계산하기 위해 일치하는 모든 문서를 스캔해야 하므로 검색 속도가 느려질 수 있어요. 다만 쿼리로 벡터 함수에 전달되는 일치 문서 수를 제한하면 지연 시간을 줄일 수 있어요. 데이터를 작은 문서 부분집합으로 필터링하면 이 방식으로도 좋은 검색 성능을 얻을 수 있어요.

근사 kNN 검색

경고

근사 kNN 검색은 특정한 리소스 요구 사항이 있어요. 예를 들어 HNSW의 경우 효율적인 성능을 위해 모든 벡터 데이터가 노드의 페이지 캐시(page cache)에 맞아야 해요. 설정 팁은 approximate kNN 튜닝 가이드를 참고하세요.

근사 kNN 검색을 실행하는 방법은 다음과 같아요.

1. 하나 이상의 dense_vector 필드를 매핑합니다. 근사 kNN 검색에는 다음 매핑 옵션이 필요해요.

  • 유사도 값(similarity value) — 쿼리 벡터와 문서 벡터 사이의 유사도로 문서에 점수를 매기는 데 쓰는 유사도 측정 기준을 결정해요. 사용 가능한 측정 기준 목록은 similarity 파라미터 문서를 참고하세요. 유사도 설정의 기본값은 cosine이에요.
PUT image-index
{
  "mappings": {
    "properties": {
      "image-vector": {
        "type": "dense_vector",
        "dims": 3,
        "similarity": "l2_norm"
      },
      "title-vector": {
        "type": "dense_vector",
        "dims": 5,
        "similarity": "l2_norm"
      },
      "title": {
        "type": "text"
      },
      "file-type": {
        "type": "keyword"
      }
    }
  }
}

2. 임베딩으로 데이터를 인덱싱합니다.

POST image-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "image-vector": [1, 5, -20], "title-vector": [12, 50, -10, 0, 1], "title": "moose family", "file-type": "jpg" }
{ "index": { "_id": "2" } }
{ "image-vector": [42, 8, -15], "title-vector": [25, 1, 4, -12, 2], "title": "alpine lake", "file-type": "png" }
{ "index": { "_id": "3" } }
{ "image-vector": [15, 11, 23], "title-vector": [1, 5, 25, 50, 20], "title": "full moon", "file-type": "jpg" }
...

3. knn 옵션이나 knn 쿼리로 검색합니다.

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [-5, 9, -12],
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title", "file-type" ]
}

문서의 _score는 결과 관련성을 랭킹하는 양수 32비트 부동소수점 숫자예요. Elasticsearch kNN 검색에서 _score는 쿼리 벡터와 문서 벡터 사이에서 선택한 벡터 유사도 측정 기준에서 파생돼요. kNN 점수 계산 방법에 대한 자세한 내용은 similarity를 참고하세요.

참고

근사 kNN 검색 지원은 버전 8.0에서 추가됐어요. 8.0 이전에는 dense_vector 필드가 매핑에서 index를 활성화하는 것을 지원하지 않았어요. 8.0 이전에 dense_vector 필드로 인덱스를 만들었다면, index: true(기본값)가 포함된 새 매핑으로 다시 인덱싱(reindex)해야 근사 kNN을 사용할 수 있어요.

근사 kNN 검색의 인덱싱 고려 사항

근사 kNN의 경우 Elasticsearch는 밀집 벡터 값을 세그먼트마다 HNSW 그래프로 저장하거나, DiskBBQ를 사용해 세그먼트마다 클러스터로 저장해요. 이런 근사 kNN 구조를 만드는 것은 계산 집약적이어서, 벡터 인덱싱이 시간이 걸릴 수 있어요. 그 결과 인덱스와 벌크(bulk) 작업의 클라이언트 요청 타임아웃을 늘려야 할 수도 있어요. approximate kNN 튜닝 가이드에는 검색 성능에 영향을 주는 인덱싱 성능, 크기, 설정 트레이드오프가 다뤄져 있어요.

Stack 9.2+

검색 시점(search-time) 파라미터 외에도 HNSW와 DiskBBQ는 그래프 구축 비용, 검색 속도, 정확도의 균형을 맞추는 인덱스 시점(index-time) 설정을 노출해요. dense_vector 매핑을 정의할 때 index_options를 사용해 이 파라미터들을 설정하세요.

semantic_text 필드 타입을 밀집 벡터 임베딩과 함께 사용하면 필드에 직접 index_options를 구성할 수도 있어요. 예시는 Optimizing vector storage with index_options를 참고하세요.

PUT image-index
{
  "mappings": {
    "properties": {
      "image-vector": {
        "type": "dense_vector",
        "dims": 3,
        "similarity": "l2_norm",
        "index_options": {
          "type": "hnsw",
          "m": 32,
          "ef_construction": 100
        }
      }
    }
  }
}

속도 또는 정확도를 위한 근사 kNN 튜닝

결과를 모으기 위해 kNN API는 먼저 샤드마다 num_candidates 개수의 근사 이웃을 찾고, 쿼리 벡터와의 유사도를 계산한 뒤 샤드마다 상위 k개를 선택하고, 이를 전역 상위 k개 최근접 이웃으로 병합해요.

HNSW 인덱스의 경우 num_candidates가 검색 시점의 속도/정확도 조절의 핵심이에요.

  • num_candidates늘리면 재현율과 정확도가 좋아져요 (지연 시간 증가 비용).
  • num_candidates줄이면 쿼리가 빨라져요 (정확도 트레이드오프 가능성).

DiskBBQ(bbq_disk) 인덱스의 경우에는 visit_percentage를 사용해 검색 중 방문하는 벡터의 총 비율을 제어할 수도 있어요. visit_percentage는 0부터 100까지의 값을 받으며, 0.5(0.5%) 같은 소수 값도 허용해요.

  • 좋은 시작 값은 3 (3%)이에요.
  • visit_percentage늘리면 재현율과 정확도가 좋아져요 (지연 시간 증가 비용).
  • visit_percentage줄이면 쿼리가 빨라져요 (정확도 트레이드오프 가능성).

검색이 HNSW와 DiskBBQ 인덱스를 모두 대상으로 할 때는 visit_percentagenum_candidates와 함께 사용해 두 인덱스 유형에 걸친 성능과 재현율을 튜닝하세요.

양자화(quantization)가 관여할 때는 rescore_vector가 추가적인 속도/정확도 튜닝 도구가 돼요. 근사 검색 후 원본 벡터로 더 큰 후보 집합을 재랭킹해요.

  • rescore_vector.oversample늘리면 정확도가 좋아져요 (지연 시간 증가 비용).
  • rescore_vector.oversample줄이면 쿼리가 빨라져요 (정확도 트레이드오프 가능성).

자세한 동작과 사용 가이드는 Oversampling and rescoring for quantized vectors을 참고하세요.

바이트 벡터를 사용한 근사 kNN

근사 kNN 검색 API는 float 벡터와 함께 바이트(int8) 값 벡터도 지원해요. element_typebyte로 설정되고 인덱싱이 활성화된 dense_vector 필드를 knn 옵션으로 검색하면 돼요. 바이트 벡터는 메모리 사용량을 줄이고 대규모 벡터 유사도 검색에서 캐시 효율을 개선할 수 있어요.

1. element_typebyte이고 인덱싱이 활성화된 dense_vector 필드를 명시적으로 매핑합니다.

PUT byte-image-index
{
  "mappings": {
    "properties": {
      "byte-image-vector": {
        "type": "dense_vector",
        "element_type": "byte",
        "dims": 2
      },
      "title": {
        "type": "text"
      }
    }
  }
}

2. 모든 벡터 값이 [-128, 127] 범위의 정수임을 확인하며 데이터를 인덱싱합니다.

POST byte-image-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "byte-image-vector": [5, -20], "title": "moose family" }
{ "index": { "_id": "2" } }
{ "byte-image-vector": [8, -15], "title": "alpine lake" }
{ "index": { "_id": "3" } }
{ "byte-image-vector": [11, 23], "title": "full moon" }

3. query_vector 값이 [-128, 127] 범위의 정수임을 확인하며 knn 옵션으로 검색합니다.

POST byte-image-index/_search
{
  "knn": {
    "field": "byte-image-vector",
    "query_vector": [-5, 9],
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title" ]
}

참고: 표준 바이트 배열 외에도 query_vector 파라미터에 hex로 인코딩된 문자열 값을 제공할 수도 있어요. 예를 들어 위 검색 요청은 다음과 같이 표현할 수도 있고, 같은 결과를 반환해요.

POST byte-image-index/_search
{
  "knn": {
    "field": "byte-image-vector",
    "query_vector": "fb09",
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title" ]
}

float 벡터를 제공하면서도 바이트 벡터의 메모리 절약 효과를 얻고 싶다면 양자화(quantization) 기능을 사용해요. 양자화를 사용하면 float 벡터를 제공하지만 내부적으로는 바이트 벡터로 인덱싱돼요. 추가로 원본 float 벡터도 여전히 인덱스에 보존돼요.

참고

float 벡터의 기본 인덱스 타입은 제품 버전과 벡터 차원에 따라 bbq_hnsw 또는 int8_hnsw예요. byte 같은 다른 요소 타입은 양자화가 없는 일반 hnsw가 기본이에요. Dense vector field type을 참고하세요.

기본 양자화 전략을 쓰거나 인덱스 옵션을 명시할 수 있어요. 예를 들어 int8_hnsw를 사용하면:

PUT quantized-image-index
{
  "mappings": {
    "properties": {
      "image-vector": {
        "type": "dense_vector",
        "element_type": "float",
        "dims": 2,
        "index": true,
        "index_options": {
          "type": "int8_hnsw"
        }
      },
      "title": {
        "type": "text"
      }
    }
  }
}

float 벡터를 인덱싱합니다.

POST quantized-image-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "image-vector": [0.1, -2], "title": "moose family" }
{ "index": { "_id": "2" } }
{ "image-vector": [0.75, -1], "title": "alpine lake" }
{ "index": { "_id": "3" } }
{ "image-vector": [1.2, 0.1], "title": "full moon" }

knn 옵션으로 검색합니다. 검색할 때 float 벡터가 자동으로 바이트 벡터로 양자화돼요.

POST quantized-image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [0.1, -2],
    "k": 10,
    "num_candidates": 100
  },
  "fields": [ "title" ]
}

원본 float 벡터가 양자화 인덱스와 함께 보존되기 때문에 이를 재스코어링(re-scoring)에 사용할 수 있어요. int8_hnsw 인덱스로 후보를 빠르게 검색한 다음, 원본 float 벡터로 상위 k개 히트를 재스코어하면 돼요. 이렇게 하면 빠른 검색과 정확한 스코어링이라는 두 장점을 모두 얻을 수 있어요.

POST quantized-image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [0.1, -2],
    "k": 15,
    "num_candidates": 100
  },
  "fields": [ "title" ],
  "rescore": {
    "window_size": 10,
    "query": {
      "rescore_query": {
        "script_score": {
          "query": {
            "match_all": {}
          },
          "script": {
            "source": "cosineSimilarity(params.query_vector, 'image-vector') + 1.0",
            "params": {
              "query_vector": [0.1, -2]
            }
          }
        }
      }
    }
  }
}

BFloat16 벡터 인코딩

Stack 9.3+

원시 벡터를 4바이트 값으로 저장하는 대신 element_type: bfloat16을 사용하면 각 차원을 2바이트 값으로 저장할 수 있어요. 인덱싱된 벡터가 이미 bfloat16 정밀도이거나, 벡터 데이터를 저장하는 데 필요한 디스크 공간을 줄이고 싶을 때 유용해요. 이 요소 타입을 사용하면 Elasticsearch는 벡터를 인덱싱할 때 4바이트 float 값을 2바이트 bfloat16 값으로 자동 반올림해요.

bfloat16의 정밀도가 줄어든 탓에, 인덱스에서 검색된 벡터는 원래 인덱싱된 값과 다소 다른 값을 가질 수 있어요.

kNN 검색 API는 필터로 벡터 유사도 검색을 제한하는 것을 지원해요. 요청은 필터 쿼리도 만족하는 상위 k개 최근접 이웃을 반환해요. 이를 통해 Elasticsearch에서 타깃이 정해진, 사전 필터링된(pre-filtered) 근사 kNN이 가능해져요.

다음 요청은 file-type 필드로 필터링된 근사 kNN 검색을 수행해요.

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [54, 10, -2],
    "k": 5,
    "num_candidates": 50,
    "filter": {
      "term": {
        "file-type": "png"
      }
    }
  },
  "fields": ["title"],
  "_source": false
}

참고

필터는 근사 kNN 검색 중에 적용되어 k개의 일치 문서가 반환되도록 해요. 이와 달리 사후 필터링(post-filtering)은 근사 kNN 단계 이후에 필터를 적용하며, 충분한 관련 문서가 존재하더라도 k개보다 적은 결과를 반환할 수 있어요.

근사 kNN 검색과 필터링

HNSW 인덱스의 근사 kNN 검색에서 필터를 적용하면 성능이 떨어질 수 있어요. 엔진이 필터를 만족하고 num_candidates에 도달할 만큼 충분한 후보를 모으기 위해 그래프를 더 많이 탐색해야 하기 때문이에요. 이는 필터가 엄격할수록 쿼리가 빨라지는 경향이 있는 일반적인 쿼리 필터링과 대조적이에요.

심각한 성능 저하를 피하기 위해 Lucene은 세그먼트마다 다음 전략을 구현해요.

  • 필터링된 문서 수가 num_candidates 이하이면, HNSW 그래프를 우회하고 필터링된 문서에 대해 brute force 검색을 수행해요.
  • HNSW 그래프를 탐색하는 동안 탐색한 노드 수가 필터를 만족하는 문서 수를 초과하면, 그래프 탐색을 멈추고 필터링된 문서에 대해 brute force 검색으로 전환해요.

근사 kNN을 다른 기능과 결합하기

knn 옵션을 일반 쿼리와 결합하면 하이브리드 검색을 수행할 수 있어요. 이렇게 하면 벡터 유사도와 어휘 관련성(lexical relevance), 필터, 집계가 혼합돼요.

POST image-index/_search
{
  "query": {
    "match": {
      "title": {
        "query": "mountain lake",
        "boost": 0.9
      }
    }
  },
  "knn": {
    "field": "image-vector",
    "query_vector": [54, 10, -2],
    "k": 5,
    "num_candidates": 50,
    "boost": 0.1
  },
  "size": 10
}

이 검색은 전역 상위 k = 5개의 벡터 매치를 찾고, 이를 match 쿼리의 매치와 결합한 다음, 최종적으로 상위 10개의 스코어링 결과를 반환해요. knnquery 매치는 마치 그 둘 사이에 boolean OR를 취한 것처럼 분리(disjunction)로 결합돼요. 상위 k개의 벡터 결과는 모든 인덱스 샤드에 걸친 전역 최근접 이웃을 나타내요.

각 결과의 점수는 knn 점수와 query 점수의 이에요. 합에서 각 점수에 가중치를 주려면 boost 값을 지정할 수 있어요. 위 예시에서 점수는 다음과 같이 계산돼요.

score = 0.9 * match_score + 0.1 * knn_score

knn 옵션은 집계(aggregation)와 함께 사용할 수도 있어요. 일반적으로 Elasticsearch는 검색과 일치하는 모든 문서에 대해 집계를 계산해요. 따라서 근사 kNN 검색에서는 상위 k개의 최근접 문서에 대해 집계가 계산돼요. 검색에 쿼리도 포함된다면, 집계는 kNN 매치와 쿼리 매치를 합친 집합에 대해 계산돼요.

최소 설정 접근 방식을 찾고 있나요? semantic_text 필드 타입은 합리적인 기본값과 자동 모델 관리를 통해 이러한 벡터 검색 구현을 추상화해요. 대부분의 사용자에게 권장되는 방식이에요. semantic_text에 대해 자세히 알아보세요.

kNN 검색을 사용하면 이전에 배포된 텍스트 임베딩 모델을 이용해 **의미 검색(semantic search)**을 수행할 수 있어요. 검색어의 리터럴 매칭 대신, 의미 검색은 검색 쿼리의 의도(intent)와 문맥적 의미를 기반으로 결과를 검색해요.

내부적으로 텍스트 임베딩 NLP 모델이 입력 쿼리 문자열(model_text로 제공)을 밀집 벡터로 변환해요. 그 쿼리 벡터는 같은 텍스트 임베딩 머신러닝 모델로 만들어진 밀집 벡터를 포함하는 인덱스와 비교돼요. 검색 결과는 모델이 학습한 대로 의미적으로 유사한 것들이에요.

중요

의미 검색을 수행하려면:

  • 검색할 입력 데이터의 밀집 벡터 표현을 포함하는 인덱스가 필요해요.
  • 문서 벡터를 만들 때 사용한 것과 같은 텍스트 임베딩 모델을 검색에도 반드시 사용해야 해요.
  • 텍스트 임베딩 NLP 모델 배포가 **시작(시작됨 상태)**되어 있어야 해요.

query_vector_builder 객체에 배포된 텍스트 임베딩 모델이나 모델 배포를 참조하고, 검색 문자열을 model_text로 제공해요.

POST image-index/_search
{
  "knn": {
    "field": "dense-vector-field",
    "k": 10,
    "num_candidates": 100,
    "query_vector_builder": {
      "text_embedding": {
        "model_id": "my-text-embedding-model",
        "model_text": "The opposite of blue"
      }
    }
  }
}

여기서 각각은 이런 의미를 가져요.

  • text_embedding — 수행할 작업(task). 이 경우 텍스트 임베딩이에요.
  • model_id — 쿼리의 밀집 벡터를 생성하는 데 사용된 텍스트 임베딩 모델의 ID예요. 대상 인덱스의 문서 임베딩을 생성한 것과 같은 모델을 사용해야 해요. model_id 값으로 deployment_id를 제공할 수도 있어요.
  • model_text — 모델이 밀집 벡터 표현을 생성하는 쿼리 문자열이에요.

query_vector_builder 옵션(text_embedding, embedding, lookup)에 대한 개요는 Build query vectors for knn search를 참고하세요.

훈련된 모델을 배포하고 이를 사용해 텍스트 임베딩을 만드는 방법에 대한 자세한 내용은 엔드투엔드 예시를 참고하세요.

여러 kNN 필드 검색하기 (Search multiple kNN fields)

하이브리드 검색 외에도 단일 요청에서 하나 이상의 kNN 벡터 필드를 검색할 수 있어요.

POST image-index/_search
{
  "query": {
    "match": {
      "title": {
        "query": "mountain lake",
        "boost": 0.9
      }
    }
  },
  "knn": [ {
    "field": "image-vector",
    "query_vector": [54, 10, -2],
    "k": 5,
    "num_candidates": 50,
    "boost": 0.1
  },
  {
    "field": "title-vector",
    "query_vector": [1, 20, -52, 23, 10],
    "k": 10,
    "num_candidates": 10,
    "boost": 0.5
  }],
  "size": 10
}

이 검색은 image-vector에 대한 전역 상위 k = 5개 이웃과 title-vector에 대한 전역 상위 k = 10개 이웃을 검색해요. 이 벡터 결과 집합들이 match 쿼리의 매치와 결합되고, 최종적으로 상위 10개의 전체 문서가 반환돼요. 여러 knn 절과 query 절은 분리(boolean OR)로 결합돼요. 상위 k개의 벡터 결과는 모든 인덱스 샤드에 걸친 전역 최근접 이웃을 나타내요.

위에서 설정한 부스트를 사용한 문서의 스코어링은 다음과 같아요.

score = 0.9 * match_score + 0.1 * knn_score_image-vector + 0.5 * knn_score_title-vector

기대 유사도로 kNN 검색하기 (Search kNN with expected similarity)

kNN은 강력한 도구이지만 항상 k개의 최근접 이웃을 반환하려고 해요. 그 결과, knn을 필터와 함께 사용하면 관련 문서를 모두 필터링해버리고 관련 없는 문서만 남겨 검색할 수도 있어요. 그런 상황에서 kNN은 그 이웃들이 벡터 공간에서 멀리 떨어져 있더라도 여전히 최선을 다해 k개의 최근접 이웃을 반환해요.

이를 제어하려면 knn 절에 similarity 파라미터를 사용해요. 이 값은 벡터가 매치로 간주되기 위해 충족해야 하는 최소 유사도 임계값을 설정해요. 이 파라미터를 사용한 kNN 검색 흐름은 다음과 같아요.

  1. 사용자가 제공한 필터 쿼리를 적용해요.
  2. 벡터 공간을 탐색해 k개의 후보를 모아요.
  3. 설정된 similarity 임계값보다 낮은 유사도의 벡터를 제외해요.

참고

similarity_score로 변환되기 전이고 어떤 boost가 적용되기 전인, 실제 유사도 값이에요.

구성된 유사도 각각에 대해, _score를 다시 기본 유사도로 되돌리는 방법은 다음과 같아요. _score를 기준으로 필터링하고 싶을 때 사용하세요.

  • l2_norm: sqrt((1 / _score) - 1)
  • cosine: (2 * _score) - 1
  • dot_product: (2 * _score) - 1
  • max_inner_product:
    • _score < 1: 1 - (1 / _score)
    • _score >= 1: _score - 1

예시: 아래 쿼리는 주어진 query_vector를 필터를 적용해 검색하고, 매치가 지정된 유사도 임계값을 충족하거나 초과할 것을 요구해요. 임계값보다 낮은 결과는 k개 미만의 이웃이 남더라도 반환되지 않아요.

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [1, 5, -20],
    "k": 5,
    "num_candidates": 50,
    "similarity": 36,
    "filter": {
      "term": {
        "file-type": "png"
      }
    }
  },
  "fields": ["title"],
  "_source": false
}

이 데이터셋에서 file-type = png인 유일한 문서의 벡터는 [42, 8, -15]이에요. [42, 8, -15][1, 5, -20] 사이의 l2_norm 거리는 41.412로, 구성된 유사도 임계값 36을 초과해요. 결과적으로 이 검색은 히트를 반환하지 않아요.

텍스트가 모델의 토큰 한도를 초과하면, 각 청크(chunk)의 임베딩을 생성하기 전에 청킹(chunking)을 수행해야 해요. dense_vector와 중첩(nested) 필드를 결합하면 최상위 문서 메타데이터를 복사하지 않고도 가장 가까운 구절(nearest passage) 검색을 수행할 수 있어요.

참고 — 중첩 kNN 쿼리는 score_mode=max만 지원해요.

다음은 벡터와 필터링을 위한 몇 가지 최상위 메타데이터를 저장하는 기본적인 구절 벡터 인덱스예요.

PUT passage_vectors
{
    "mappings": {
        "properties": {
            "full_text": {
                "type": "text"
            },
            "creation_time": {
                "type": "date"
            },
            "paragraph": {
                "type": "nested",
                "properties": {
                    "vector": {
                        "type": "dense_vector",
                        "dims": 2,
                        "index_options": {
                            "type": "hnsw"
                        }
                    },
                    "text": {
                        "type": "text",
                        "index": false
                    },
                    "language": {
                        "type": "keyword"
                    }
                }
            },
            "metadata": {
                "type": "nested",
                "properties": {
                    "key": {
                        "type": "keyword"
                    },
                    "value": {
                        "type": "text"
                    }
                }
            }
        }
    }
}

위 매핑으로 개별 구절 텍스트를 저장하면서 여러 구절 벡터를 인덱싱할 수 있어요.

POST passage_vectors/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "full_text": "first paragraph another paragraph", "creation_time": "2019-05-04", "paragraph": [ { "vector": [ 0.45, 45 ], "text": "first paragraph", "paragraph_id": "1", "language": "EN" }, { "vector": [ 0.8, 0.6 ], "text": "another paragraph", "paragraph_id": "2", "language": "FR" } ], "metadata": [ { "key": "author", "value": "Jane Doe" }, { "key": "source", "value": "Internal Memo" } ] }
{ "index": { "_id": "2" } }
{ "full_text": "number one paragraph number two paragraph", "creation_time": "2020-05-04", "paragraph": [ { "vector": [ 1.2, 4.5 ], "text": "number one paragraph", "paragraph_id": "1", "language": "EN" }, { "vector": [ -1, 42 ], "text": "number two paragraph", "paragraph_id": "2", "language": "EN" }] , "metadata": [ { "key": "author", "value": "Jane Austen" }, { "key": "source", "value": "Financial" } ] }

쿼리는 전형적인 kNN 검색과 매우 비슷해 보여요.

POST passage_vectors/_search
{
    "fields": ["full_text", "creation_time"],
    "_source": false,
    "knn": {
        "query_vector": [
            0.45,
            45
        ],
        "field": "paragraph.vector",
        "k": 2
    }
}

총 4개의 중첩 벡터가 있어도 응답은 여전히 두 문서를 반환한다는 점을 기억하세요. 중첩 dense_vector에 대한 kNN 검색은 항상 최상위 문서에 걸쳐 상위 결과를 다양화해요. 가장 가까운 구절 벡터(예: paragraph.vector)로 점수가 매겨진 "k"개의 최상위 문서가 반환돼요.

{
    "took": 4,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 1.0,
        "hits": [
            {
                "_index": "passage_vectors",
                "_id": "1",
                "_score": 1.0,
                "fields": {
                    "creation_time": [
                        "2019-05-04T00:00:00.000Z"
                    ],
                    "full_text": [
                        "first paragraph another paragraph"
                    ]
                }
            },
            {
                "_index": "passage_vectors",
                "_id": "2",
                "_score": 0.9997144,
                "fields": {
                    "creation_time": [
                        "2020-05-04T00:00:00.000Z"
                    ],
                    "full_text": [
                        "number one paragraph number two paragraph"
                    ]
                }
            }
        ]
    }
}

중첩 kNN 검색에서 필터링

중첩 kNN 검색에서 메타데이터로 필터링하고 싶나요? knn 절에 필터를 추가하면 돼요.

올바른 결과를 보장하려면 각 개별 필터가 다음 중 하나만 대상으로 해야 해요.

  • 최상위 메타데이터
  • 중첩 메타데이터

Stack 9.2+

참고 — 단일 kNN 검색에 여러 필터를 포함할 수 있어요. 어떤 것은 최상위 메타데이터에, 다른 것은 중첩 메타데이터에 적용할 수 있어요.

POST passage_vectors/_search
{
    "fields": [
        "creation_time",
        "full_text"
    ],
    "_source": false,
    "knn": {
        "query_vector": [0.45, 45],
        "field": "paragraph.vector",
        "k": 2,
        "filter": {
            "range": {
                "creation_time": {
                    "gte": "2019-05-01",
                    "lte": "2019-05-05"
                }
            }
        }
    }
}

최상위 creation_time 필터를 적용하면 지정된 범위 안에 들어오는 문서는 하나뿐이에요.

{
    "took": 4,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 1,
            "relation": "eq"
        },
        "max_score": 1.0,
        "hits": [
            {
                "_index": "passage_vectors",
                "_id": "1",
                "_score": 1.0,
                "fields": {
                    "creation_time": [
                        "2019-05-04T00:00:00.000Z"
                    ],
                    "full_text": [
                        "first paragraph another paragraph"
                    ]
                }
            }
        ]
    }
}

중첩 메타데이터 필터링

Stack 9.2+

다음 쿼리는 중첩 메타데이터 필터를 적용해요. 부모 문서에 점수를 매길 때 paragraph.languageEN인 중첩 벡터만 고려해요.

POST passage_vectors/_search
{
    "fields": [
        "full_text"
    ],
    "_source": false,
    "knn": {
        "query_vector": [0.45, 45],
        "field": "paragraph.vector",
        "k": 2,
        "filter": {
            "match": {
                "paragraph.language": "EN"
            }
        }
    }
}

다음 예시는 중첩 메타데이터 필터와 최상위 메타데이터 필터 두 개를 결합해요. 부모 문서는 paragraph.languageEN이고 부모 문서가 지정된 시간 범위 안에 있는 벡터로만 점수가 매겨져요.

POST passage_vectors/_search
{
    "fields": [
        "full_text"
    ],
    "_source": false,
    "knn": {
        "query_vector": [0.45,45],
        "field": "paragraph.vector",
        "k": 2,
        "filter": [
            {"match": {"paragraph.language": "EN"}},
            {"range": { "creation_time": { "gte": "2019-05-01", "lte": "2019-05-05"}}}
        ]
    }
}

중첩 kNN 검색에서 형제 중첩 필드로 필터링

Stack 9.2+

중첩 kNN 검색은 **형제 중첩 필드(sibling nested fields)**에 대한 사전 필터링도 지원해요. 예를 들어 paragraphsmetadata가 중첩 필드로 주어졌을 때, paragraphs.vector를 검색하면서 metadata.keymetadata.value로 필터링할 수 있어요.

POST passage_vectors/_search
{
    "fields": [
        "full_text"
    ],
    "_source": false,
    "knn": {
        "query_vector": [0.45, 45],
        "field": "paragraph.vector",
        "k": 2,
        "filter": {
            "nested": {
                "path": "metadata",
                "query": {
                    "bool": {
                        "must": [
                            { "match": { "metadata.key": "author" } },
                            { "match": { "metadata.value": "Doe" } }
                        ]
                    }
                }
            }
        }
    }
}

참고 — 형제 중첩 필드로 필터링할 때 inner_hits를 검색하는 것은 지원되지 않아요.

Inner hits를 사용한 중첩 kNN 검색

일치하는 각 부모 문서에 대해 가장 가까운 구절을 추출하려면 knn 절에 inner_hits를 추가해요.

참고 — 여러 knn 절과 함께 inner_hits를 사용할 때는 각 절에 고유한 inner_hits.name을 설정해 검색 요청을 실패시키는 이름 충돌을 피하세요.

POST passage_vectors/_search
{
    "fields": [
        "creation_time",
        "full_text"
    ],
    "_source": false,
    "knn": {
        "query_vector": [
            0.45,
            45
        ],
        "field": "paragraph.vector",
        "k": 2,
        "num_candidates": 2,
        "inner_hits": {
            "_source": false,
            "fields": [
                "paragraph.text"
            ],
            "size": 1
        }
    }
}

이제 결과에는 검색 시 찾은 가장 가까운 구절이 포함돼요.

{
    "took": 4,
    "timed_out": false,
    "_shards": {
        "total": 1,
        "successful": 1,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 2,
            "relation": "eq"
        },
        "max_score": 1.0,
        "hits": [
            {
                "_index": "passage_vectors",
                "_id": "1",
                "_score": 1.0,
                "fields": {
                    "creation_time": [
                        "2019-05-04T00:00:00.000Z"
                    ],
                    "full_text": [
                        "first paragraph another paragraph"
                    ]
                },
                "inner_hits": {
                    "paragraph": {
                        "hits": {
                            "total": {
                                "value": 2,
                                "relation": "eq"
                            },
                            "max_score": 1.0,
                            "hits": [
                                {
                                    "_index": "passage_vectors",
                                    "_id": "1",
                                    "_nested": {
                                        "field": "paragraph",
                                        "offset": 0
                                    },
                                    "_score": 1.0,
                                    "fields": {
                                        "paragraph": [
                                            {
                                                "text": [
                                                    "first paragraph"
                                                ]
                                            }
                                        ]
                                    }
                                }
                            ]
                        }
                    }
                }
            },
            {
                "_index": "passage_vectors",
                "_id": "2",
                "_score": 0.9997144,
                "fields": {
                    "creation_time": [
                        "2020-05-04T00:00:00.000Z"
                    ],
                    "full_text": [
                        "number one paragraph number two paragraph"
                    ]
                },
                "inner_hits": {
                    "paragraph": {
                        "hits": {
                            "total": {
                                "value": 2,
                                "relation": "eq"
                            },
                            "max_score": 0.9997144,
                            "hits": [
                                {
                                    "_index": "passage_vectors",
                                    "_id": "2",
                                    "_nested": {
                                        "field": "paragraph",
                                        "offset": 1
                                    },
                                    "_score": 0.9997144,
                                    "fields": {
                                        "paragraph": [
                                            {
                                                "text": [
                                                    "number two paragraph"
                                                ]
                                            }
                                        ]
                                    }
                                }
                            ]
                        }
                    }
                }
            }
        ]
    }
}

청크된 콘텐츠를 위한 중첩 벡터 검색

Elasticsearch에서 dense_vector 필드와 inner_hits를 사용한 중첩 kNN 검색을 이용하면 구조화된, 청크된 문서에서 가장 관련성 높은 구절을 검색할 수 있어요.

이 접근 방식은 다음과 같은 경우에 이상적이에요.

  • 콘텐츠를 단락, 섹션 또는 기타 중첩 구조로 청킹할 때
  • 일치하는 각 문서의 가장 관련성 높은 중첩 섹션만 검색하고 싶을 때
  • Elastic의 의미 검색 기능이 제공하는 semantic_text 필드에 의존하는 대신 커스텀 모델로 직접 벡터를 생성할 때

인덱스 매핑 만들기

이 예시는 문서 제목에 대한 벡터를 최상위에 저장하고, 개별 단락에 대한 여러 벡터를 중첩 필드 안에 저장하는 인덱스를 만들어요.

PUT nested_vector_index
{
  "mappings": {
    "properties": {
      "paragraphs": {
        "type": "nested",
        "properties": {
          "text": {
            "type": "text"
          },
          "vector": {
            "type": "dense_vector",
            "dims": 2,
            "index_options": {
              "type": "hnsw"
            }
          }
        }
      }
    }
  }
}

문서 인덱싱

각 단락에 대한 벡터가 있는 예시 문서를 추가해요.

POST _bulk
{ "index": { "_index": "nested_vector_index", "_id": "1" } }
{ "paragraphs": [ { "text": "First paragraph", "vector": [0.5, 0.4] }, { "text": "Second paragraph", "vector": [0.3, 0.8] } ] }
{ "index": { "_index": "nested_vector_index", "_id": "2" } }
{ "paragraphs": [ { "text": "Another one", "vector": [0.1, 0.9] } ] }

검색 쿼리 실행

이 예시는 관련 단락 벡터가 있는 문서를 검색해요.

POST nested_vector_index/_search
{
  "_source": false,
  "knn": {
    "field": "paragraphs.vector",
    "query_vector": [0.5, 0.4],
    "k": 2,
    "num_candidates": 10,
    "inner_hits": {
      "size": 2,
      "name": "top_passages",
      "_source": false,
      "fields": ["paragraphs.text"]
    }
  }
}

inner_hits 블록은 각 최상위 문서 내에서 가장 관련성 높은 단락을 반환해요. size 파라미터를 사용해 반환되는 매치 수를 제어해요. 쿼리에 여러 kNN 절이 포함되어 있다면 응답의 이름 충돌을 피하기 위해 각 절에 고유한 name을 설정하세요.

{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      {
        "_index": "nested_vector_index",
        "_id": "1",
        "_score": 1,
        "inner_hits": {
          "top_passages": {
            "hits": {
              "total": {
                "value": 2,
                "relation": "eq"
              },
              "max_score": 1,
              "hits": [
                {
                  "_index": "nested_vector_index",
                  "_id": "1",
                  "_nested": {
                    "field": "paragraphs",
                    "offset": 0
                  },
                  "_score": 1,
                  "fields": {
                    "paragraphs": [
                      {
                        "text": [
                          "First paragraph"
                        ]
                      }
                    ]
                  }
                },
                {
                  "_index": "nested_vector_index",
                  "_id": "1",
                  "_nested": {
                    "field": "paragraphs",
                    "offset": 1
                  },
                  "_score": 0.92955077,
                  "fields": {
                    "paragraphs": [
                      {
                        "text": [
                          "Second paragraph"
                        ]
                      }
                    ]
                  }
                }
              ]
            }
          }
        }
      },
      {
        "_index": "nested_vector_index",
        "_id": "2",
        "_score": 0.8535534,
        "inner_hits": {
          "top_passages": {
            "hits": {
              "total": {
                "value": 1,
                "relation": "eq"
              },
              "max_score": 0.8535534,
              "hits": [
                {
                  "_index": "nested_vector_index",
                  "_id": "2",
                  "_nested": {
                    "field": "paragraphs",
                    "offset": 0
                  },
                  "_score": 0.8535534,
                  "fields": {
                    "paragraphs": [
                      {
                        "text": [
                          "Another one"
                        ]
                      }
                    ]
                  }
                }
              ]
            }
          }
        }
      }
    ]
  }
}
  • 문서 점수 — 쿼리에 일치한 문서 두 건. 가장 관련성 높은 단락을 기준으로 한 문서 점수.
  • inner_hits 섹션 — 쿼리와 일치하는 단락.
  • 실제 단락 텍스트 — 쿼리와 일치한 실제 단락 텍스트.

근사 kNN 검색의 제한 사항

  • 교차 클러스터 검색(cross-cluster search)에서 kNN 검색을 사용할 때는 ccs_minimize_roundtrips 옵션이 지원되지 않아요.
  • Elasticsearch는 효율적인 kNN을 위해 HNSW 알고리즘을 사용해요. 대부분의 근사 방법처럼 HNSW도 완벽한 정확도를 속도와 맞바꾸기 때문에, 결과가 항상 진짜 k개의 최근접 이웃은 아니에요.

참고 — 근사 kNN은 항상 dfs_query_then_fetch 검색 타입을 사용해 샤드 간 전역 상위 k 매치를 수집해요. kNN 검색에 대해 search_type을 명시적으로 설정할 수 없어요.

양자화 벡터의 오버샘플링과 재스코어링 (Oversampling and rescoring for quantized vectors)

kNN 검색에 양자화 벡터를 사용할 때 다음을 수행해 성능과 정확도의 균형을 맞추기 위해 결과를 선택적으로 재스코어링할 수 있어요.

  • 오버샘플링(Oversampling) — 샤드당 더 많은 후보를 검색.
  • 재스코어링(Rescoring) — 오버샘플링된 후보에 대해 원본(비양자화) 벡터로 점수를 다시 계산.

최종 점수가 원본 float 벡터로 계산되므로, 재스코어링은 다음을 결합해요.

  • 양자화 벡터를 사용한 근사 검색의 성능 및 메모리 이점
  • 상위 후보 재스코어링에 원본 벡터를 사용한 정확도

모든 양자화는 어느 정도의 정확도 손실을 도입하며, 더 높은 압축은 일반적으로 그 손실을 증가시켜요. 실무에서는:

  • int8은 일반적으로 재스코어링이 거의 또는 전혀 필요 없어요.
  • int4는 더 높은 정확도나 재현율을 위해 재스코어링의 혜택을 받는 경우가 많아요. 1.5×–2× 오버샘플링이 대개 대부분의 손실을 회복해요.
  • bbq는 매우 큰 인덱스나 양자화를 위해 특별히 설계된 모델을 제외하고 일반적으로 재스코어링이 필요해요. 3×–5× 오버샘플링이 대체로 충분하지만, 저차원 벡터나 양자화가 잘 안 되는 임베딩에서는 더 높은 값이 필요할 수 있어요.

rescore_vector 옵션

Stack 9.1+ (GA)

rescore_vector를 사용해 재랭킹을 자동으로 수행하세요. oversample 값을 지정하면 근사 kNN은 다음을 수행해요.

  1. 샤드당 num_candidates 개의 후보를 검색.
  2. 원본 벡터로 샤드당 상위 k * oversample 개의 후보를 재스코어.
  3. 상위 k개의 재스코어된 후보 반환.

oversample 파라미터와 함께 rescore_vector 옵션을 사용하는 예시는 다음과 같아요.

POST image-index/_search
{
  "knn": {
    "field": "image-vector",
    "query_vector": [-5, 9, -12],
    "k": 10,
    "num_candidates": 100,
    "rescore_vector": {
      "oversample": 2.0
    }
  },
  "fields": [ "title", "file-type" ]
}

이 예시는 다음을 수행해요.

  1. 근사 kNN으로 상위 100개 후보를 검색.
  2. 원본 비양자화 벡터로 샤드당 상위 20개 후보(oversample * k)를 재스코어.
  3. 상위 10개(k) 재스코어된 후보 반환.
  4. 모든 샤드의 재스코어된 후보를 병합하고 상위 10개(k) 결과 반환.

on_disk_rescore 옵션

Serverless: 사용 불가 | Stack 9.3+ (Preview)

기본적으로 Elasticsearch는 재스코어링을 수행하기 위해 원시 벡터 데이터를 메모리로 읽어요. 벡터 데이터가 너무 커서 한 번에 off-heap 메모리에 모두 맞지 않으면 성능에 영향을 줄 수 있어요. on_disk_rescore: true 인덱스 설정이 설정되면 Elasticsearch는 재스코어링 중 벡터 데이터를 디스크에서 직접 읽어요.

이 설정은 새로 인덱싱된 벡터에만 적용돼요. 설정 변경 후 인덱스의 모든 벡터에 이 옵션을 적용하려면 벡터를 다시 인덱싱하거나 force-merge 해야 해요.

추가적인 재스코어링 기법

다음 섹션은 추가적인 재스코어링 방법을 제공해요.

최상위 kNN 검색에 rescore 섹션 사용하기

각 샤드에서 재스코어링하는 대신 모든 샤드의 상위 결과에 재스코어링하고 싶을 때 사용할 수 있는 옵션이에요.

_search 요청의 rescore 섹션을 사용해 kNN 검색의 상위 결과를 재스코어링해요.

다음은 최상위 knn 검색을 오버샘플링과 함께 사용하고 rescore로 결과를 재랭킹하는 예시예요.

POST /my-index/_search
{
  "size": 10,
  "knn": {
    "query_vector": [0.04283529, 0.85670587, -0.51402352, 0],
    "field": "my_int4_vector",
    "k": 20,
    "num_candidates": 50
  },
  "rescore": {
    "window_size": 20,
    "query": {
      "rescore_query": {
        "script_score": {
          "query": {
            "match_all": {}
          },
          "script": {
            "source": "(dotProduct(params.queryVector, 'my_int4_vector') + 1.0)",
            "params": {
              "queryVector": [0.04283529, 0.85670587, -0.51402352, 0]
            }
          }
        }
      },
      "query_weight": 0,
      "rescore_query_weight": 1
    }
  }
}
  • size: 10 — 반환할 결과 수예요. 10개뿐이고 2x 오버샘플링을 하므로 20개의 최근접 이웃을 모아요.
  • k: 20 — kNN 검색에서 반환할 결과 수예요. HNSW 그래프당 50개 후보로 근사 kNN 검색을 수행하고 양자화 벡터를 사용해, 양자화 점수에 따라 가장 유사한 20개 벡터를 반환해요. 게다가 최상위 knn 객체이므로 재스코어링 전에 모든 샤드의 전역 상위 20개 결과를 모아요. 이를 rescore와 결합하면 2x 오버샘플링이 되어, 양자화 점수에 따른 20개 최근접 이웃을 모으고 더 높은 충실도의 float 벡터로 재스코어링해요.
  • window_size: 20 — 재스코어링할 결과 수예요. 모든 결과를 재스코어링하고 싶다면 k와 같은 값으로 설정하세요.
  • 스크립트 — 결과를 재스코어링하는 스크립트예요. 스크립트 점수는 원래 제공된 float32 벡터와 직접 상호작용해요.
  • query_weight: 0 — 원래 쿼리의 가중치예요. 여기서는 원래 점수를 버려요.
  • rescore_query_weight: 1 — 재스코어 쿼리의 가중치예요. 여기서는 재스코어 쿼리만 사용해요.

script_score 쿼리로 샤드별 재스코어링

각 샤드에서 재스코어링하고 싶고, rescore_vector 옵션이 제공하는 것보다 더 세밀하게 재스코어링을 제어하고 싶을 때 사용할 수 있는 옵션이에요.

knn 쿼리와 script_score 쿼리로 샤드별 재스코어링을 수행해요. 일반적으로 샤드당 재스코어링이 더 많아지지만, 계산 비용을 대가로 전체 재현율을 높일 수 있어요.

POST /my-index/_search
{
  "size": 10,
  "query": {
    "script_score": {
      "query": {
        "knn": {
          "query_vector": [0.04283529, 0.85670587, -0.51402352, 0],
          "field": "my_int4_vector",
          "num_candidates": 20
        }
      },
      "script": {
        "source": "(dotProduct(params.queryVector, 'my_int4_vector') + 1.0)",
        "params": {
          "queryVector": [0.04283529, 0.85670587, -0.51402352, 0]
        }
      }
    }
  }
}
  • size: 10 — 반환할 결과 수예요.
  • knn 쿼리 — 초기 검색을 수행하는 kNN 쿼리로, 샤드별로 실행돼요.
  • num_candidates: 20 — 초기 근사 kNN 검색에 사용할 후보 수예요. 양자화 벡터로 검색해 점수를 매길 샤드당 상위 20개 후보를 반환해요.
  • 스크립트 — 결과에 점수를 매기는 스크립트예요. 스크립트 점수는 원래 제공된 float32 벡터와 직접 상호작용해요.

정확 kNN 검색을 실행하려면 벡터 함수가 들어 있는 script_score 쿼리를 사용해요.

1. 하나 이상의 dense_vector 필드를 명시적으로 매핑합니다. 필드를 근사 kNN에 사용할 의도가 없다면 매핑 index 옵션을 false로 설정하세요. 이렇게 하면 인덱싱 속도를 크게 개선할 수 있어요.

PUT product-index
{
  "mappings": {
    "properties": {
      "product-vector": {
        "type": "dense_vector",
        "dims": 5,
        "index": false
      },
      "price": {
        "type": "long"
      }
    }
  }
}

2. 데이터를 인덱싱합니다.

POST product-index/_bulk?refresh=true
{ "index": { "_id": "1" } }
{ "product-vector": [230.0, 300.33, -34.8988, 15.555, -200.0], "price": 1599 }
{ "index": { "_id": "2" } }
{ "product-vector": [-0.5, 100.0, -13.0, 14.8, -156.0], "price": 799 }
{ "index": { "_id": "3" } }
{ "product-vector": [0.5, 111.3, -13.0, 14.8, -156.0], "price": 1099 }
...

3. 검색 API를 사용해 벡터 함수가 포함된 script_score 쿼리를 실행합니다.

벡터 함수에 전달되는 일치 문서 수를 제한하려면 script_score.query 파라미터에 필터 쿼리를 지정하는 것을 권장해요. 필요하다면 이 파라미터에 match_all 쿼리를 사용해 모든 문서를 일치시킬 수도 있어요. 하지만 모든 문서를 일치시키면 검색 지연 시간이 크게 증가할 수 있어요.

POST product-index/_search
{
  "query": {
    "script_score": {
      "query" : {
        "bool" : {
          "filter" : {
            "range" : {
              "price" : {
                "gte": 1000
              }
            }
          }
        }
      },
      "script": {
        "source": "cosineSimilarity(params.queryVector, 'product-vector') + 1.0",
        "params": {
          "queryVector": [-0.5, 90.0, -10, 14.8, -156.0]
        }
      }
    }
  }
}

k-최근접 이웃(kNN) 검색은 유사도 측정 기준으로 측정한 쿼리 벡터에서 가장 가까운 k개의 벡터를 찾아요.

kNN의 대표적인 사용 사례는 다음과 같아요.

  • 자연어 처리(NLP) 알고리즘을 기반으로 한 관련성 랭킹
  • 상품 추천 및 추천 엔진
  • 이미지나 비디오에 대한 유사도 검색

— 밀집 벡터 임베딩을 Elasticsearch로 수집하는 방법을 배우고 싶다면 핸즈온 튜토리얼을 확인해 보세요.