벡터 유사도 검색 익스텐션
벡터 유사도 검색 익스텐션 (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_join과vss_match)는HNSW인덱스를 요구하지도 사용하지도 않아요.