전용 서버(Qdrant)와 Postgres 확장(pgvector)을 해부한 뒤, 세 번째 진영이 궁금해졌다. 바로 파일인 벡터 데이터베이스다. LanceDB가 그 진영이다. connect()는 프로세스 안에서 경로를 열고, 저장소는 버전이 관리되는 Lance 컬럼형 포맷이며, 인덱스는 HNSW 그래프가 아니라 IVF-PQ다. 나는 **lancedb v0.30.0의 커밋 a5288de**와 **lance v8.0.0의 커밋 15f2ff5**를 읽었다. 모든 주장은 코드의 해당 줄을 가리킨다.
승부수: 서버가 아니라 파일
클라이언트와 임베디드 사이의 결정은 분기 하나가 전부다. ConnectBuilder::execute(connection.rs:910-932)는 uri.starts_with("db")를 확인한다. 여기에 걸리면 LanceDB Cloud HTTP 클라이언트로 가고, 그 밖의 모든 것(로컬 경로, s3://, gs://, az://)은 프로세스 안에서 실행되는 ListingDatabase로 흘러간다. 시작할 데몬도 없고 대신 시작되는 데몬도 없다. ListingDatabase는 객체 스토리지를 직접 열며, 로컬 경로라면 그저 디렉터리에 create_dir_all을 실행한다(listing.rs:550-560). “테이블”은 그 루트 아래의 <name>.lance 디렉터리이고(listing.rs:218-234), 프로세스 안에서 NativeTable로 열린다(listing.rs:1198-1211). 배포되는 라이브러리에서 프로세스나 소켓 관련 장치를 grep해봤다. src에 있는 유일한 Command::spawn은 테스트 유틸리티이며(test_utils/connection.rs:96-101), #[cfg(test)] 뒤에 가려져 있다(lib.rs:183-184). TcpListener도, bind도, listen도 없다.
솔직히 밝혀야 할 뉘앙스가 두 가지 있다. 첫째, 이 분기는 스킴 일치가 아니라 접두사 일치다. 충분히 그럴듯한 로컬 디렉터리 이름인 connect("db_data")는 starts_with("db")에 걸려 원격 분기로 향하고, API 키와 리전이 없어 오류가 난다(connection.rs:876-881). 로컬로 되돌아가는 fallback은 없다. Python 동기 바인딩은 startswith("db://")를 제대로 확인하므로(__init__.py:207), 이 문제는 Python 사용자가 아니라 Rust 사용자에게 생긴다. 둘째, 원격 경로는 정말 별도의 코드다. remote cargo feature 뒤에 있으며(lib.rs:179-180, 그렇지 않으면 connection.rs:901-907에서 stub 오류가 난다), https://{db}.{region}.api.lancedb.com을 가리키는 reqwest 클라이언트다(client.rs:426-428). Rust crate는 default=[]로 배포된다. 원격 기능은 컴파일 시점에 선택한다(Cargo.toml:111). 하지만 Python과 Node 바인딩은 이를 기본으로 컴파일하므로(python/Cargo.toml:49, nodejs/Cargo.toml:43), pip/npm 사용자에게 임베디드와 클라우드의 분리는 오직 URI에 따라 런타임에 결정된다.
그리고 “서버 없음”을 정확히 말하자면, s3:// URI도 여전히 스토리지 클라이언트로서 네트워크 I/O를 수행한다. 연산(스캔, 인덱스 검색, 커밋)은 프로세스 안에 남는다. “서버 없음”은 정확히 “LanceDB 서버 없음”을 뜻한다.
저장소: 커밋은 새로운 매니페스트 파일이다
서버가 없다면 일관성은 누가 소유할까? 포맷이 소유한다. 특정 버전에서 Lance 데이터셋의 상태는 매니페스트 파일 하나다. 프래그먼트와 그 데이터 파일을 가리키는 스냅샷이며(table.proto:36-47), 커밋은 _versions/{N}.manifest처럼 다음 매니페스트를 쓰는 것을 뜻한다(commit.rs:93-110. V2 명명 방식도 존재하므로 “예를 들면”이다). 쓰기는 생성 전용이다. RenameCommitHandler는 rename_if_not_exists를 사용하고 AlreadyExists를 CommitConflict로 매핑한다(commit.rs:1417-1468). 객체 스토리지 핸들러는 PutMode::Create를 사용한다(commit.rs:1476-1532). 두 writer가 버전 N+1을 두고 경합하면 하나는 이기고 하나는 충돌을 받는다. 버전 번호는 올라가기만 한다. new_from_previous가 증가시키기 때문에(manifest.rs:202-231, transaction.rs:2332-2334), 스냅샷 수준에서 버저닝은 추가 전용이다.
데이터 자체는 컬럼형이다. 프래그먼트의 각 데이터 파일은 같은 행에 대한 열의 일부를 담고(fragment.rs:474-506, table.proto:308-380), 열 메타데이터는 개별 읽기, 즉 열 프로젝션에 맞게 배치된다(file2.proto:161-163). 임베딩에 특별한 자리는 없다. 벡터는 그저 메타데이터 열 옆에 놓이는 FixedSizeList 열이다(datatypes.rs:223-237).
그 결과는 기계적으로 따라온다. 삭제는 데이터 파일을 다시 쓰지 않는다. 새로운 soft-tombstone 삭제 파일을 쓴다(fragment.rs:1812-1880, table.proto:436-465에 따르면 _deletions/ 아래에 저장된다). 업데이트와 compaction은 프래그먼트를 다시 쓰지만, 언제나 새로운 매니페스트 버전으로 들어간다(transaction.rs:400-424, :343-350). 그리고 커밋된 버전은 절대 변경되지 않으므로 time travel은 덤으로 따라온다. checkout_version은 과거의 어떤 스냅샷이든 다시 연다(dataset.rs:433). 이전 버전은 정리할 때까지 남는다. 자동 정리 기능은 있지만 설정을 통해 엄격하게 opt-in해야 하므로, 기본적으로 명시적인 정리를 수행하기 전까지 히스토리만큼 저장 공간을 쓴다.
인덱스: 어디에나 IVF, 압축에는 PQ
비교가 정말로 갈리는 지점은 여기다. Qdrant와 pgvector는 모두 HNSW에 승부를 걸었다. 메모리에 상주하려는 성향을 지닌, full-precision 벡터 위의 탐색 가능 그래프다. Lance는 IVF에 건다. 벡터를 k-means 셀로 나누고 소수의 셀만 검색한다. 그리고 여러 선택지 중 하나에 그치지 않는다. 일곱 가지 벡터 인덱스 유형이 모두 IVF_*다. IVF_FLAT, IVF_SQ, IVF_PQ, IVF_HNSW_SQ, IVF_HNSW_PQ, IVF_HNSW_FLAT, IVF_RQ다(lib.rs:137-146). 인덱스 builder도 이를 강제한다. stages[0]이 Ivf가 아니면 오류다(vector.rs:524-529).
쿼리는 쿼리 벡터와 모든 centroid 사이의 거리를 계산하고 정렬한 뒤 상위 nprobes개 파티션을 가져간다(kmeans.rs:1305-1329, ivf.rs:1182-1192). 기본값은 고정된 작은 probe 수가 아니다. minimum_nprobes = 1에서 시작하고 k개 결과가 나오지 않으면 모든 파티션까지 확장한다(scanner.rs:1587-1588. maximum_nprobes의 기본값은 모든 파티션이다. ivf.rs:1189).
파티션 안에서 PQ는 disk-first 구조를 가능하게 하는 압축이다. 각 벡터를 하위 벡터로 나누고(기본값은 16개, 각각 8 bit다. builder.rs:56-66), 각각을 codebook 코드로 양자화한다(pq.rs:43-54). 쿼리 시점에 엔진은 쿼리에서 codeword까지의 거리 lookup table을 만들고(distance.rs:22-55), 코드에 해당하는 테이블 항목을 더해 각 거리를 근사한다. asymmetric distance computation이며 원본 벡터는 한 번도 복원하지 않는다(distance.rs:124-159).
그렇다면 HNSW는? 여기에도 존재하지만, IVF 파티션 안에서만 셀 내부를 검색하는 하위 인덱스로 쓰인다. HNSW 하위 인덱스에 파티션 선택을 요청하면 말 그대로 panic한다. find_partitions가 unimplemented!("only for IVF")이기 때문이다(hnsw/index.rs:186-192). Qdrant와 pgvector에서는 HNSW 그래프가 인덱스 전체다. Lance에서 그래프는 기껏해야 IVF 셀 안의 세입자다. IVF_PQ는 이 계열의 canonical 구성원이다. 레거시 Vector enum 값이 말 그대로 "IVF_PQ"의 alias다(lib.rs:139,163). 다만 한 가지는 정확히 범위를 한정하겠다. lance Python dataset API에는 기본 인덱스 유형이 아예 없다. index_type은 필수 인자다(dataset.py:3733-3736, 일곱 가지 유형의 whitelist는 dataset.py:3451-3463에 있다). lancedb 제품 계층이 무엇을 기본값으로 삼는지는 여기서 검증한 범위 밖이다.

LanceDB의 임베디드 경로는 파일 포맷 파이프라인이다. 프로세스 안에서 Lance 테이블을 열고, 벡터를 열로 유지하며, IVF로 분할하고, PQ로 압축한 뒤, 가장 가까운 행을 반환한다.
어디에 놓일까?
축은 두 개이고, LanceDB는 둘 다에서 새로운 위치를 차지한다.
| Qdrant | pgvector | LanceDB | |
|---|---|---|---|
| 존재 형태 | 전용 서버 | Postgres 확장 | 임베디드 라이브러리 + 파일 포맷 |
| 인덱스 계열 | HNSW 그래프(빌드 시점의 페이로드 링크) | HNSW 그래프(쿼리 시점의 반복 스캔) | IVF 우선, PQ 압축. HNSW는 셀 안에서만 사용 |
| 데이터가 사는 곳 | 엔진 소유 저장소 | Postgres 페이지, WAL 기록 | 로컬 디스크 또는 객체 스토리지의 Lance 파일 |
| 일관성 방식 | 서버가 조정 | Postgres MVCC | 생성 전용 매니페스트 커밋, 버전이 관리되는 스냅샷 |
첫 번째 축은 벡터 데이터베이스가 무엇으로 존재하는가다. 직접 운영하는 서버인지, 이미 운영하는 데이터베이스 안의 확장인지, 프로세스가 여는 파일 포맷인지다. 두 번째는 인덱스 계열이다. HNSW 그래프는 메모리 상주와 full-precision 쪽으로 기운다. IVF-PQ는 과감하게 압축하고 거칠게 분할하며, 바로 이 형태가 디스크와 객체 스토리지에서 살아남는다. LanceDB의 두 승부수는 같은 승부수를 두 번 건 것이다. 상주 프로세스도, 상주 그래프도 없다.
언제 선택할까?
데이터가 이미 S3나 로컬 디스크에 있고, 서버를 운영하고 싶지 않으며, workload가 임베디드, 배치, 분석 형태라면 LanceDB를 선택하면 된다. 데이터셋을 열고 검색하는 인덱싱 파이프라인, notebook, 데스크톱 앱, lambda가 그 예다. pip install 하나로 버전이 관리되고 time travel이 가능한 저장소와 벡터 검색을 얻는다. 컬럼형 포맷 덕분에 메타데이터와 임베딩은 하나의 파일 레이아웃을 공유한다.
트레이드오프는 알고 들어가야 한다. 동시 writer는 조정 서버가 아니라 커밋 충돌로 해결된다. 생성 전용 매니페스트 쓰기 때문에 경합하는 writer는 CommitConflict를 받는다. 이는 올바른 동작이지만 multi-writer OLTP 이야기는 아니다. hot cache와 admission control을 사용하는 저지연 온라인 서빙은 서버 엔진이 답을 내놓는 영역이다. 인덱스 빌드는 명시적이다. 직접 인덱스를 만든다. 서버 엔진처럼 뒤에서 그래프를 점진적으로 유지해주는 것은 없다. 그리고 Rust에서는 db 접두사를 조심해야 한다. 로컬 디렉터리 이름을 db_anything으로 지으면 connect()가 클라우드로 데려가려 한다.
방법론과 범위
나는 **lance v8.0.0의 커밋 15f2ff5**와 **lancedb v0.30.0의 커밋 a5288de**를 읽었다. lancedb에서는 connection/database 계층을, lance에서는 commit 경로, table format proto, vector index crate를 읽었다. 핵심 주장 세 가지, 즉 데몬 없는 임베디드 라우팅, 컬럼형 포맷 위의 추가 전용 매니페스트 버저닝, IVF 우선/PQ 압축 인덱스 계열은 각각 이 커밋에서 적대적 주장 검증을 거쳤고 확인됐다.
검증 중에 한 가지 정정이 있었고, 밝힐 가치가 있다. 내 원래 주장은 “db:// 스킴만 원격으로 라우팅된다”였다. 이는 반박됐다. 코드는 db 접두사를 일치시킨다(connection.rs:910-932). 위에서 읽은 것이 바로 정정된 표현이다.
범위의 주의점을 명확히 밝힌다.
- 이것은 벤치마크가 아니라 구조 분석이다. IVF-PQ와 HNSW의 재현율, 지연 시간, 메모리 동작은 데이터,
nprobes, PQ 매개변수에 따라 달라진다. 여기서는 아무것도 측정하지 않았다. - “서버 없음”은 LanceDB 데몬이 없다는 뜻이다. 객체 스토리지 URI는 여전히 스토리지 클라이언트로서 네트워크 I/O를 수행하고,
connect_namespace("rest")는 명시적으로 opt-in하는 네트워크 경로다. - Qdrant와 pgvector에 대한 HNSW 비교는 두 저장소를 이번 글에서 다시 검증한 것이 아니라 앞서 쓴 티어다운에서 가져왔다.
- “기본 인덱스 유형 없음”은 lance Python dataset API로 범위가 한정된다. lancedb 제품 계층은 자체 기본값을 제공할 수도 있지만, 이는 검증하지 않았다.



