개요

Brute-force KNN Search(완전 탐색 최근접 이웃 검색)는 쿼리 벡터와 데이터셋의 모든 벡터 간 거리를 정확히 계산하여 가장 가까운 k개의 이웃을 찾는 검색 방법이다. 완전 탐색(Exhaustive Search) 또는 선형 검색(Linear Search)이라고도 불린다.

100% 정확한 결과를 보장하지만, 데이터셋 크기에 비례하여 계산 비용이 증가하므로 소규모 데이터셋이나 정확도가 필수적인 경우에 주로 사용된다. 대규모 벡터 검색에서는 HNSW 같은 근사 최근접 이웃(ANN) 알고리즘을 사용하는 것이 일반적이다.

KNN 알고리즘과의 차이

혼동하기 쉬운 두 개념을 명확히 구분해야 한다:

구분KNN 알고리즘Brute-force KNN Search
분야머신러닝 (지도 학습)벡터 검색
목적분류/회귀 예측유사 벡터 찾기
입력특징 벡터 + 레이블쿼리 벡터 + 벡터 DB
출력클래스 또는 예측값k개의 최근접 벡터
최적화KD-Tree, Ball Tree (저차원)최적화 불가 (항상 O(N))
사용 예손글씨 인식, 추천 시스템시맨틱 검색, 유사 이미지 검색

핵심 차이:

  • KNN 알고리즘: 레이블이 있는 학습 데이터로 새로운 데이터를 분류하거나 예측
  • Brute-force KNN Search: 벡터 데이터베이스에서 유사한 벡터를 검색

KNN 알고리즘 내부에서 brute-force search를 사용할 수 있지만, 두 개념은 서로 다른 목적과 맥락에서 사용된다.

작동 원리

알고리즘

  1. 거리 계산: 쿼리 벡터 와 데이터셋의 모든 벡터 간의 거리 계산
  2. 정렬: 거리를 기준으로 오름차순 정렬
  3. 선택: 가장 가까운 k개의 벡터 반환

의사 코드

def brute_force_knn(query, dataset, k, distance_metric):
    distances = []
 
    # 모든 벡터와의 거리 계산
    for vector in dataset:
        dist = distance_metric(query, vector)
        distances.append((dist, vector))
 
    # 거리 기준 정렬
    distances.sort(key=lambda x: x[0])
 
    # 상위 k개 반환
    return distances[:k]

거리 메트릭

일반적으로 사용되는 거리 함수:

유클리드 거리 (L2 Distance)

코사인 유사도 (Cosine Similarity)

내적 (Inner Product)

복잡도 분석

시간 복잡도

  • : 데이터셋 크기 (벡터 개수)
  • : 벡터 차원

예시:

  • 100만 개 벡터, 768차원 → 약 7억 6800만 번 연산
  • 1억 개 벡터, 1536차원 → 약 1조 5360억 번 연산

공간 복잡도

원본 벡터 데이터만 저장하면 되므로 공간 효율적이다. 별도의 인덱스 구조가 필요 없다.

확장성 문제

데이터셋 크기와 차원이 증가하면 검색 시간이 선형적으로 증가한다:

데이터셋 크기차원단일 쿼리 시간 (예상)
1만 개128~1ms
10만 개384~50ms
100만 개768~500ms
1000만 개1536~10초

장점과 단점

장점

  • 100% 정확도: 항상 정확한 최근접 이웃 반환
  • 단순성: 구현이 매우 간단하고 이해하기 쉬움
  • 전처리 불필요: 인덱스 구축 시간이 없음 ()
  • 메모리 효율: 원본 데이터만 저장하면 됨
  • 동적 데이터: 삽입/삭제가 즉시 반영됨
  • 고차원 안정성: 차원의 저주가 있지만 정확도는 유지

단점

  • 느린 검색: 시간 복잡도로 대규모 데이터에 부적합
  • 확장성 부족: 데이터가 증가하면 선형적으로 느려짐
  • 실시간 부적합: 밀리초 단위 응답이 필요한 경우 사용 불가
  • 배치 쿼리 비효율: 여러 쿼리를 동시에 처리해도
  • 병렬화 한계: 분산 처리해도 근본적인 복잡도는 동일

Brute-force vs ANN

정확도-속도 트레이드오프

측면Brute-force SearchANN (예: HNSW)
정확도100%95~99%
시간 복잡도
공간 복잡도
전처리 시간없음
단일 쿼리 (100만 벡터)~500ms~1ms
확장성낮음높음
사용 사례소규모, 정확도 필수대규모, 실시간 검색

언제 무엇을 사용할까?

Brute-force Search가 적합한 경우:

  • 데이터셋 크기 < 10,000개
  • 100% 정확도가 필수적인 경우
  • 실시간 응답이 필요 없는 경우
  • 인덱스 구축 비용을 피하고 싶은 경우
  • 데이터가 자주 변경되는 경우
  • 벤치마크나 정확도 검증 목적

ANN이 적합한 경우:

  • 데이터셋 크기 > 100,000개
  • 실시간 검색이 필요한 경우 (< 100ms)
  • 95~99% 정확도로 충분한 경우
  • 대규모 프로덕션 환경
  • 높은 QPS(Queries Per Second) 요구

최적화 기법

brute-force 방식 자체는 최적화가 어려우나, 구현 수준의 개선은 가능하다.

1. 벡터화 연산 (Vectorization)

NumPy, BLAS 등을 활용한 행렬 연산으로 속도 향상:

import numpy as np
 
def brute_force_knn_vectorized(query, dataset, k):
    # 모든 거리를 한 번에 계산 (벡터화)
    distances = np.linalg.norm(dataset - query, axis=1)
 
    # 상위 k개 인덱스 찾기
    top_k_indices = np.argpartition(distances, k)[:k]
 
    return top_k_indices

2. GPU 가속

CUDA, cuML 등을 사용하여 대량의 거리 계산을 병렬화:

  • CPU: 순차 계산
  • GPU: 수천 개 코어로 병렬 계산
  • 수십~수백 배 속도 향상 가능

3. 사전 필터링

쿼리에 메타데이터 조건을 추가하여 검색 대상 축소:

# 예: 카테고리가 "electronics"인 것만 검색
filtered_dataset = dataset[dataset['category'] == 'electronics']
results = brute_force_knn(query, filtered_dataset, k)

4. 조기 종료 (Early Termination)

정렬 대신 부분 정렬(partial sort) 사용:

import heapq
 
def brute_force_knn_heap(query, dataset, k):
    # 최소 k개만 유지하는 힙 사용
    heap = []
 
    for vector in dataset:
        dist = distance(query, vector)
 
        if len(heap) < k:
            heapq.heappush(heap, (-dist, vector))
        elif dist < -heap[0][0]:
            heapq.heapreplace(heap, (-dist, vector))
 
    return [v for _, v in heap]

5. 근사 거리 계산

정확한 거리 대신 근사값 사용:

  • 제곱근 생략: L2 거리에서 제곱근 계산 생략
  • 양자화: 벡터를 저정밀도로 변환 (int8, float16)

구현 예시

NumPy 기본 구현

import numpy as np
 
class BruteForceKNN:
    def __init__(self, metric='euclidean'):
        self.metric = metric
        self.data = None
 
    def fit(self, X):
        """데이터 저장"""
        self.data = np.array(X)
 
    def search(self, query, k=10):
        """k개의 최근접 이웃 검색"""
        query = np.array(query)
 
        if self.metric == 'euclidean':
            # L2 거리 계산
            distances = np.linalg.norm(self.data - query, axis=1)
        elif self.metric == 'cosine':
            # 코사인 유사도 계산 (1 - similarity로 거리화)
            norm_data = self.data / np.linalg.norm(self.data, axis=1, keepdims=True)
            norm_query = query / np.linalg.norm(query)
            similarities = np.dot(norm_data, norm_query)
            distances = 1 - similarities
 
        # 상위 k개 인덱스
        top_k_indices = np.argpartition(distances, k)[:k]
        top_k_indices = top_k_indices[np.argsort(distances[top_k_indices])]
 
        return top_k_indices, distances[top_k_indices]
 
# 사용 예시
knn = BruteForceKNN(metric='euclidean')
knn.fit(vectors)  # N x D 행렬
indices, distances = knn.search(query_vector, k=10)

Scikit-learn 사용

from sklearn.neighbors import NearestNeighbors
 
# Brute-force 알고리즘 명시
knn = NearestNeighbors(n_neighbors=10, algorithm='brute', metric='euclidean')
knn.fit(vectors)
 
# 검색
distances, indices = knn.kneighbors([query_vector])

FAISS 사용

import faiss
import numpy as np
 
# 차원
d = 768
 
# Brute-force 인덱스 (Flat)
index = faiss.IndexFlatL2(d)  # L2 거리
# 또는
# index = faiss.IndexFlatIP(d)  # 내적
 
# 데이터 추가
index.add(vectors)  # N x d numpy array
 
# 검색
k = 10
distances, indices = index.search(query_vectors, k)

벡터 데이터베이스에서의 구현

Elasticsearch

POST /my-index/_search
{
  "query": {
    "script_score": {
      "query": {"match_all": {}},
      "script": {
        "source": "cosineSimilarity(params.query_vector, 'vector_field') + 1.0",
        "params": {"query_vector": [0.1, 0.2, ...]}
      }
    }
  }
}

특징:

  • script_score는 brute-force 방식
  • 소규모 데이터셋이나 필터링 후 사용 권장
  • 대규모에서는 knn 검색 사용

OpenSearch

GET /my-index/_search
{
  "query": {
    "script_score": {
      "query": {"match_all": {}},
      "script": {
        "source": "1 / (1 + l2Squared(params.query_vector, doc['vector_field']))",
        "params": {"query_vector": [0.1, 0.2, ...]}
      }
    }
  }
}

Pinecone

Pinecone은 항상 인덱스 기반 검색을 사용하며, 순수 brute-force 옵션은 제공하지 않는다.

실무 활용 패턴

1. 하이브리드 검색

1차로 메타데이터 필터링 → 2차로 brute-force 벡터 검색:

# 1차: 필터링 (카테고리, 날짜 등)
filtered_data = data[data['price'] < 100]
 
# 2차: Brute-force 검색
results = brute_force_knn(query, filtered_data, k=10)

2. 재순위화 (Re-ranking)

1차로 ANN 검색 → 2차로 brute-force 정밀 검색:

# 1차: ANN으로 후보 100개 추출
candidates = ann_index.search(query, k=100)
 
# 2차: Brute-force로 정확한 상위 10개 선택
top_10 = brute_force_knn(query, candidates, k=10)

3. 정확도 벤치마킹

ANN 알고리즘의 recall을 측정할 때 ground truth로 사용:

# Ground truth (정답)
true_neighbors = brute_force_knn(query, dataset, k=100)
 
# ANN 결과
ann_neighbors = hnsw_index.search(query, k=100)
 
# Recall 계산
recall = len(set(true_neighbors) & set(ann_neighbors)) / len(true_neighbors)

4. 소규모 임베딩 캐시

자주 사용되는 소량의 벡터를 메모리에 캐시:

class VectorCache:
    def __init__(self, max_size=10000):
        self.cache = {}  # 최대 1만 개
        self.knn = BruteForceKNN()
 
    def search(self, query, k=10):
        # 캐시된 벡터에서만 brute-force 검색
        return self.knn.search(query, k)

참고 자료

학술 자료

기술 문서

비교 및 분석