벡터 유사도 검색 익스텐션

벡터 유사도 검색 익스텐션 (Vector Similarity Search Extension)

vss 익스텐션은 DuckDB의 실험적 익스텐션으로, DuckDB의 새 고정 크기 ARRAY 타입을 사용해 벡터 유사도 검색 쿼리를 가속화하는 인덱싱 지원을 추가해요.

발표 블로그 포스트"벡터 유사도 검색 익스텐션의 새로운 기능" 포스트를 참고해요.

출처: 문서

본문

사용법 (Usage)

ARRAY 열이 있는 테이블에 새 HNSW(Hierarchical Navigable Small Worlds) 인덱스를 만들려면 USING HNSW 절과 함께 CREATE INDEX 문을 사용해요. 예를 들어:

INSTALL vss;
LOAD vss;

CREATE TABLE my_vector_table (vec FLOAT[3]);
INSERT INTO my_vector_table
    SELECT array_value(a, b, c)
    FROM range(1, 10) ra(a), range(1, 10) rb(b), range(1, 10) rc(c);
CREATE INDEX my_hnsw_index ON my_vector_table USING HNSW (vec);

그러면 인덱스가 지원하는 거리 메트릭 함수 중 하나를 인덱스된 열과 상수 벡터에 대해 평가하고 LIMIT 절이 뒤따르는 ORDER BY 절을 사용하는 쿼리를 가속화하는 데 쓰여요. 예를 들어:

SELECT *
FROM my_vector_table
ORDER BY array_distance(vec, [1, 2, 3]::FLOAT[3])
LIMIT 3;

추가로, 오버로드된 min_by(col, arg, n)arg 인자가 일치하는 거리 메트릭 함수라면 HNSW 인덱스로 가속화될 수 있어요. 이는 빠른 일회성 최근접 이웃 검색에 쓸 수 있어요. 예를 들어 [1, 2, 3]에 가장 가까운 벡터를 가진 상위 3개 행을 얻으려면:

SELECT min_by(my_vector_table, array_distance(vec, [1, 2, 3]::FLOAT[3]), 3 ORDER BY vec) AS result
FROM my_vector_table;
[{'vec': [1.0, 2.0, 3.0]}, {'vec': [2.0, 2.0, 3.0]}, {'vec': [1.0, 2.0, 4.0]}]

min_by의 첫 번째 인자로 테이블 이름을 전달해 일치한 전체 행을 담은 struct를 반환받는 점을 주목해요.

EXPLAIN 출력에서 플랜에 HNSW_INDEX_SCAN 노드를 찾아 인덱스가 사용되는지 확인할 수 있어요:

EXPLAIN
SELECT *
FROM my_vector_table
ORDER BY array_distance(vec, [1, 2, 3]::FLOAT[3])
LIMIT 3;
┌───────────────────────────┐
│         PROJECTION        │
│   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│             #0            │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│         PROJECTION        │
│   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│            vec            │
│array_distance(vec, [1.0, 2│
│         .0, 3.0])         │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│      HNSW_INDEX_SCAN      │
│   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│   t1 (HNSW INDEX SCAN :   │
│           my_idx)         │
│   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│            vec            │
│   ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│           EC: 3           │
└───────────────────────────┘

기본적으로 HNSW 인덱스는 DuckDB의 array_distance 함수와 일치하는 유클리드 거리 l2sq(L2-norm 제곱) 메트릭으로 생성되지만, 인덱스 생성 시 metric 옵션을 지정해 다른 거리 메트릭을 사용할 수 있어요. 예를 들어:

CREATE INDEX my_hnsw_cosine_index
ON my_vector_table
USING HNSW (vec)
WITH (metric = 'cosine');

다음 표는 지원되는 거리 메트릭과 그에 해당하는 DuckDB 함수를 보여 줘요.

Metric Function Description
l2sq array_distance Euclidean distance
cosine array_cosine_distance Cosine similarity distance
ip array_negative_inner_product Negative inner product

HNSW 인덱스는 단일 열에만 적용되지만, 같은 테이블에 각각 다른 열을 인덱싱하는 여러 HNSW 인덱스를 만들 수 있어요. 또한 각각 다른 거리 메트릭을 지원하는 여러 HNSW 인덱스를 같은 열에 만들 수도 있어요.

인덱스 옵션 (Index Options)

metric 옵션 외에도 HNSW 인덱스 생성 문은 인덱스 구축·검색 과정의 하이퍼파라미터를 제어하는 다음 옵션을 지원해요.

옵션 (Option) 기본값 (Default) 설명 (Description)
ef_construction 128 인덱스 구축 중 고려할 후보 정점 수. 값이 높을수록 인덱스가 더 정확해지지만 구축 시간도 늘어나요.
ef_search 64 인덱스 검색 단계에서 고려할 후보 정점 수. 값이 높을수록 인덱스가 더 정확해지지만 검색 시간도 늘어나요.
M 16 그래프의 각 정점에 유지할 최대 이웃 수. 값이 높을수록 인덱스가 더 정확해지지만 구축 시간도 늘어나요.
M0 2 * M 기본 연결성, 즉 그래프의 0번째 레벨에서 각 정점에 유지할 이웃 수. 값이 높을수록 인덱스가 더 정확해지지만 구축 시간도 늘어나요.

추가로, 런타임에 SET hnsw_ef_search = ⟨int⟩ 설정 옵션을 설정해 인덱스 구축 시점에 설정된 ef_search 파라미터를 재정의할 수 있어요. 이는 연결별로 검색 성능과 정확도를 맞바꾸고 싶을 때 유용해요. RESET hnsw_ef_search를 호출해 재정의를 해제할 수도 있어요.

영속성 (Persistence)

커스텀 익스텐션 인덱스의 영속성과 관련된 알려진 문제 때문에, SET hnsw_enable_experimental_persistence = ⟨bool⟩ 설정 옵션을 true로 설정하지 않는 한 HNSW 인덱스는 기본적으로 인메모리 데이터베이스의 테이블에서만 만들 수 있어요.

이 기능을 실험 플래그 뒤에 잠근 이유는 "WAL" 복구가 커스텀 인덱스에 아직 제대로 구현되지 않았기 때문이에요. 즉 HNSW 인덱스된 테이블에 커밋되지 않은 변경이 있는 동안 크래시가 발생하거나 데이터베이스가 예기치 않게 종료되면 인덱스의 데이터 손실이나 손상이 생길 수 있어요.

이 옵션을 켜고 예기치 않은 종료를 겪었다면, 먼저 DuckDB를 별도로 시작하고 vss 익스텐션을 로드한 뒤 데이터베이스 파일을 ATTACH해 인덱스를 복구해 볼 수 있어요. 이러면 WAL-재생 중에 HNSW 인덱스 기능이 사용 가능해져 DuckDB의 복구 과정이 문제없이 진행돼요. 하지만 프로덕션 환경에서는 이 기능을 쓰지 않는 걸 여전히 권장해요.

hnsw_enable_experimental_persistence 옵션을 켜면 인덱스가 DuckDB 데이터베이스 파일에 영속화돼요(디스크 기반 데이터베이스 파일로 DuckDB를 실행하는 경우). 즉 데이터베이스를 재시작한 뒤 인덱스를 처음부터 다시 만들 필요 없이 디스크에서 메모리로 다시 로드할 수 있어요. 참고로 영속 인덱스 저장소에 대한 증분 업데이트는 없어서, DuckDB가 checkpoint를 수행할 때마다 전체 인덱스가 디스크에 직렬화되고 자신을 덮어써요. 마찬가지로 데이터베이스를 재시작하면 인덱스가 통째로 주 메모리로 역직렬화돼요. 다만 이는 인덱스와 연결된 테이블에 처음 접근할 때까지 지연돼요. 인덱스 크기에 따라 역직렬화 과정이 시간이 걸릴 수 있지만, 인덱스를 버리고 다시 만드는 것보다는 여전히 빠를 거예요.

삽입·업데이트·삭제와 재압축

HNSW 인덱스는 인덱스 생성 후 테이블에서 행을 삽입·업데이트·삭제하는 것을 지원해요. 다만 두 가지를 염두에 둬야 해요:

  • 테이블에 데이터가 채워진 뒤 인덱스를 만드는 게 더 빨라요. 초기 벌크 로드가 큰 테이블에서 병렬성을 더 잘 활용할 수 있기 때문이에요.
  • 삭제는 인덱스에 즉시 반영되지 않고 "표시(marked)"만 되어, 시간이 지나면서 인덱스가 낡아가고 쿼리 품질과 성능에 부정적 영향을 줄 수 있어요.

마지막 점을 해결하려면 PRAGMA hnsw_compact_index('⟨index_name⟩') 프래그마 함수를 호출해 삭제된 항목을 가지치기하는 인덱스 재압축을 트리거하거나, 상당한 수의 업데이트 후 인덱스를 다시 만들 수 있어요.

보너스: 벡터 유사도 검색 조인

vss 익스텐션은 여러 벡터를 서로 매칭하는 것을 단순화하는 "퍼지 조인(fuzzy joins)"이라 불리는 테이블 매크로 몇 개도 제공해요. 그 목록은:

  • vss_join(left_table, right_table, left_col, right_col, k, metric := 'l2sq')
  • vss_match(right_table, left_col, right_col, k, metric := 'l2sq')

이들은 현재 HNSW 인덱스를 사용하지 않지만, 조인 로직을 직접 작성하지 않고 brute-force 벡터 유사도 검색을 수행해도 괜찮은 사용자를 위한 편의 유틸리티 함수로 제공돼요. 향후 이들은 인덱스 기반 최적화의 대상이 될 수도 있어요.

이 함수들은 이렇게 사용할 수 있어요:

CREATE TABLE haystack (id int, vec FLOAT[3]);
CREATE TABLE needle (search_vec FLOAT[3]);

INSERT INTO haystack
    SELECT row_number() OVER (), array_value(a, b, c)
    FROM range(1, 10) ra(a), range(1, 10) rb(b), range(1, 10) rc(c);

INSERT INTO needle
    VALUES ([5, 5, 5]), ([1, 1, 1]);

SELECT *
FROM vss_join(needle, haystack, search_vec, vec, 3) res;
┌───────┬─────────────────────────────────┬─────────────────────────────────────┐
│ score │            left_tbl             │              right_tbl              │
│ float │   struct(search_vec float[3])   │  struct(id integer, vec float[3])   │
├───────┼─────────────────────────────────┼─────────────────────────────────────┤
│   0.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 365, 'vec': [5.0, 5.0, 5.0]} │
│   1.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 364, 'vec': [5.0, 4.0, 5.0]} │
│   1.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 356, 'vec': [4.0, 5.0, 5.0]} │
│   0.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 1, 'vec': [1.0, 1.0, 1.0]}   │
│   1.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 10, 'vec': [2.0, 1.0, 1.0]}  │
│   1.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 2, 'vec': [1.0, 2.0, 1.0]}   │
└───────┴─────────────────────────────────┴─────────────────────────────────────┘

혹은 vss_match 매크로를 "lateral join"으로 사용해 매칭을 왼쪽 테이블별로 이미 그룹화해 얻을 수 있어요. 이러려면 왼쪽 테이블을 먼저 지정하고, 왼쪽 테이블의 검색 열(이 경우 search_vec)을 참조하는 vss_match 매크로를 지정해야 해요:

SELECT *
FROM needle, vss_match(haystack, search_vec, vec, 3) res;
┌─────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│   search_vec    │                                                                                       matches                                                                                        │
│    float[3]     │                                                            struct(score float, "row" struct(id integer, vec float[3]))[]                                                             │
├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [5.0, 5.0, 5.0] │ [{'score': 0.0, 'row': {'id': 365, 'vec': [5.0, 5.0, 5.0]}}, {'score': 1.0, 'row': {'id': 364, 'vec': [5.0, 4.0, 5.0]}}, {'score': 1.0, 'row': {'id': 356, 'vec': [4.0, 5.0, 5.0]}}] │
│ [1.0, 1.0, 1.0] │ [{'score': 0.0, 'row': {'id': 1, 'vec': [1.0, 1.0, 1.0]}}, {'score': 1.0, 'row': {'id': 10, 'vec': [2.0, 1.0, 1.0]}}, {'score': 1.0, 'row': {'id': 2, 'vec': [1.0, 2.0, 1.0]}}]]
└─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

제한 사항 (Limitations)

  • 현재는 FLOAT(32비트, 단정밀도)로 구성된 벡터만 지원돼요.
  • 인덱스 자체는 버퍼 관리되지 않으며 RAM 메모리에 들어가야 해요.
  • 메모리 내 인덱스 크기는 DuckDB의 memory_limit 설정 파라미터에 계산되지 않아요.
  • SET hnsw_enable_experimental_persistence = ⟨bool⟩ 설정 옵션을 true로 설정하지 않는 한 HNSW 인덱스는 인메모리 데이터베이스의 테이블에서만 만들 수 있어요. 자세한 내용은 Persistence를 참고해요.
  • 벡터 조인 테이블 매크로(vss_joinvss_match)는 HNSW 인덱스를 요구하지도 사용하지도 않아요.

더 알아보기 (Learn more)