> 벡터 검색 알고리즘 입문과 HNSW 심화에서 HNSW 그래프 자체는 다뤘다. > 이 글은 그 그래프가 범용 검색엔진(OpenSearch) 안에 어떻게 얹히는가 — Lucene 세그먼트 구조 위에서 kNN이 도는 방식이다. OpenSearch를 벡터 DB로 쓸 때 성능이 왜 그렇게 나오는지는 엔진을 열어봐도 안 보인다. 답은 그 밑의 Lucene 세그먼트...
벡터 검색 알고리즘 입문과 HNSW 심화에서 HNSW 그래프 자체는 다뤘다. 이 글은 그 그래프가 범용 검색엔진(OpenSearch) 안에 어떻게 얹히는가 — Lucene 세그먼트 구조 위에서 kNN이 도는 방식이다.
OpenSearch를 벡터 DB로 쓸 때 성능이 왜 그렇게 나오는지는 엔진을 열어봐도 안 보인다. 답은 그 밑의 Lucene 세그먼트 구조에 있다. 한 줄로 요약하면 이렇다 — 벡터 인덱스는 세그먼트에 종속된다. 세그먼트마다 HNSW 그래프가 하나씩 불변으로 만들어지고, kNN 쿼리는 샤드 안 모든 세그먼트 그래프로 fan-out 해서 각 top-k를 병합한다. 이 한 문장이 "세그먼트가 많으면 느리다", "force merge로 빨라진다", "OpenSearch 벡터 검색 QPS 천장" 을 전부 설명한다.
Lucene은 문서를 넣을 때 텍스트를 term으로 분석해 "term → 그 term을 가진 문서 목록(posting list)" 매핑을 만든다. 이게 inverted index다 — 문서에서 단어를 찾는 게 아니라, 단어에서 문서를 역방향으로 찾는다. 그리고 Lucene 인덱스는 이 inverted index를 통째로 하나 두는 게 아니라, 작은 inverted index 여러 개의 모음이다. 그 하나하나가 세그먼트다.
세그먼트는 한번 쓰이면 고쳐지지 않는다(immutable).
불변이라 얻는 게 크다. 락 없이 여러 스레드가 동시에 읽을 수 있고, OS 페이지 캐시에 안전하게 올라가 한번 캐시되면 무효화될 일이 없다. 벡터 검색에서 이 불변성이 그대로 그래프에 이어진다(2부).
들어온 문서는 먼저 RAM 버퍼에 쌓인다. 이 상태에서는 아직 검색되지 않는다.
refresh 시점에 버퍼를 flush 해서 OS 페이지 캐시에 새 세그먼트를 만들고, 그때부터 그 문서가 검색된다.
refresh_interval 기본값이 1초라, 문서를 넣고 약 1초 뒤 검색된다 — 이것이 준실시간(near-real-time) 검색의 정체다.
간격을 늘리거나 끄면 작은 세그먼트가 덜 생겨 색인 처리량이 오른다. 대량 적재 때 refresh를 끄는 게 표준 튜닝인 이유다.
refresh가 계속 작은 세그먼트를 찍어내므로, 백그라운드에서 이들을 큰 세그먼트로 합친다. Lucene 기본 정책은 TieredMergePolicy로, 크기가 비슷하거나 삭제 표시 비율이 높은 세그먼트를 골라 합친다. 병합은 선택된 세그먼트를 읽어 삭제 문서를 빼고 남은 문서만 새 큰 세그먼트로 다시 쓴 뒤, 원본 작은 세그먼트를 지운다. 이때 비로소 tombstone이 실제로 회수된다. 세그먼트 수가 줄어 검색이 열어볼 대상이 적어진다.
_forcemerge는 병합을 수동으로 강제해 세그먼트 수를 목표치(max_num_segments, 흔히 1)까지 줄인다.
세그먼트가 적을수록 읽기가 빨라지므로, 더 이상 쓰기가 없는 읽기 전용 인덱스에 주로 쓴다.
주의할 점이 있다.
정리하면 계층은 이렇다 — 인덱스 = 샤드 여러 개, 샤드 1개 = Lucene 인덱스 1개 = 세그먼트 여러 개. 쿼리는 인덱스의 모든 샤드로 뿌려지고(coordinating 노드가 취합), 각 샤드 안에서는 그 샤드의 모든 세그먼트를 훑어 부분 결과를 모은 뒤 병합한다. 이 "샤드로 fan-out, 세그먼트로 다시 fan-out" 구조가 벡터 검색에서 그대로 성능을 좌우한다.
핵심 사실이다. 벡터 인덱스(HNSW 그래프)는 세그먼트마다 별도로 만들어진다. OpenSearch 공식 문서는 native 인덱스가 "knn_vector 필드 × 세그먼트 조합마다 1개" 생긴다고 적는다. Elastic도 "세그먼트마다 HNSW 그래프를 탐색한다"고 명시한다.
즉 한 샤드에 세그먼트가 N개면 그 샤드의 HNSW 그래프도 N개다. 세그먼트가 불변이니 그래프도 세그먼트에 묶여 불변이고, 새 데이터는 새 세그먼트의 새 그래프로 들어간다.
하나의 kNN 쿼리는 각 세그먼트의 그래프를 각각 탐색해 세그먼트별 top-k를 구한 뒤, 이들을 병합해 전역 top-k를 만든다. 그래서 세그먼트가 많으면 느려진다. 이유가 두 가지다.
세그먼트를 1개로 force merge 하면 HNSW 그래프도 1개가 된다. fan-out이 사라져 검색이 빨라진다. OpenSearch 공식 문서가 이걸 직접 권장한다 — "샤드당 세그먼트 1개가 검색 지연 관점에서 최적"이라고.
단 조건이 붙는다. "초기 대량 적재 1회 → 이후 검색 전용" 워크로드에만 권장된다.
force merge 시 벡터 자료구조가 다시 만들어지지만(rebuild), 그 뒤로는 그래프가 1개로 안정돼 fan-out이 없어지기 때문이다.
운영 함정도 하나 있다 — 병합을 돌리면 기존 그래프가 native 메모리에서 내려가고 새 그래프가 올라온다. warmup으로 미리 올려둔 그래프도 병합으로 무효화되니, warmup 대상 인덱스에는 병합을 돌리지 않는다.
세 엔진 모두 "세그먼트마다 그래프 1개" 구조는 공유한다. 차이는 그래프를 누가 만들고 어디에 두느냐다.
핵심 대비 — faiss/nmslib는 native 라이브러리가 별도 세그먼트 파일을 만들고, lucene 엔진은 Lucene 벡터 저장에 직접 통합된다.
HNSW 그래프가 어디에 상주하는지도 엔진에 따라 갈린다.
knn.memory.circuit_breaker.limit(기본 노드 메모리의 50%)로 제한된다. 한 머신에 담을 벡터 수는 off-heap 용량의 함수다. 세그먼트 파일이 처음 검색될 때 올라오며 warmup으로 미리 올릴 수 있다.어느 쪽이든 원칙은 같다 — 그래프는 JVM 힙이 아니라 힙 밖에 둔다(힙은 다른 검색·집계용). 이 얘기는 OpenSearch를 벡터 DB로 굴리며 알게 된 것에서 실측으로 다뤘다.
한 kNN 쿼리가 세그먼트를 병렬로 탐색하는지, 순차로 하는지는 엔진·버전에 따라 다르므로 단정하면 안 된다.
병렬이 되든 안 되든 force merge로 세그먼트를 줄이는 최적화는 여전히 유효하다 — 그래프 수와 병합 낭비 자체가 줄기 때문이다.
앞선 대규모 벤치에서 OpenSearch의 QPS가 다른 전용 벡터 DB보다 낮게 나온 게 이 구조로 설명된다.
즉 OpenSearch 벡터 검색 튜닝은 "세그먼트를 줄여 그래프를 모으기"와 "샤드를 늘려 병렬을 얻기" 사이의 절충이다. 이 절충 구조 자체가 범용 검색엔진에 벡터를 얹었을 때의 본질적 특성이고, 전용 벡터 DB(Milvus 등)와 갈리는 지점이다(OpenSearch vs Milvus).
OpenSearch를 벡터 DB로 운영·튜닝할 때 이 구조에서 나오는 판단 기준이다.
refresh_interval을 늘리거나 꺼서 작은 세그먼트 난립을 막는다.circuit_breaker.limit과 실제 벡터 크기를 같이 본다.