본문 바로가기
Q&A해결2026년 8월 21일2분 읽기

pgvector에서 <=> 로 유사도 top-k 뽑는데 HNSW 인덱스를 안 타고 seq scan만 합니다

v
vector_dain
조회 550댓글 4

RAG 파이프라인 만들면서 문서 임베딩을 pgvector에 넣고 있습니다. 테이블에 embedding vector(1536) 컬럼이 있고 HNSW 인덱스도 만들어놨는데, 정작 검색 쿼리를 날리면 인덱스를 안 타고 seq scan으로 전체를 훑습니다. 행이 30만 개쯤 되니까 쿼리 한 번에 700ms씩 걸려요 😭

인덱스는 이렇게 만들었습니다.

CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);

실제 쿼리는 이렇습니다. 특정 카테고리 안에서만 가장 가까운 10개를 뽑으려는 거예요.

EXPLAIN ANALYZE
SELECT id FROM docs
WHERE category = 'manual'
ORDER BY embedding <=> $1
LIMIT 10;

EXPLAIN 결과를 보면 Seq Scan on docs 로 나오고 인덱스 이름은 어디에도 안 보입니다. LIMIT도 걸었고 연산자도 코사인(vector_cosine_ops)으로 맞춘 것 같은데 왜 인덱스를 무시하는지 모르겠습니다.

댓글 4

김김영삼2개월 전
인덱스 정의는 맞습니다. 문제는 WHERE category = 'manual' 필터예요.

pgvector의 HNSW는 그래프를 따라가면서 근사 최근접을 찾는 구조라, 인덱스 자체는 'embedding에 대한 정렬'만 알지 category 같은 다른 조건을 스캔 중에 걸러내지 못합니다. 그래서 옵티마이저 입장에선 '인덱스로 가까운 순서대로 뽑아도 category='manual'이 아닌 게 잔뜩 섞여 나와서 LIMIT 10을 못 채울 수 있다'고 보고, 차라리 seq scan 후 정렬이 싸다고 판단해서 인덱스를 버립니다. 필터가 셀렉티브할수록 이 현상이 심해져요.

먼저 인덱스가 살아있긴 한지부터 확인하세요. SET enable_seqscan = off; 걸고 같은 쿼리를 EXPLAIN 해보면 인덱스를 쓰게 강제됩니다. 여기서 Index Scan using ...hnsw 가 뜨면 인덱스 자체는 정상이고 순전히 플래너의 선택 문제인 거예요.

제대로 된 해결은 두 갈래입니다. category가 몇 개 안 되고 각 카테고리가 크면 부분 인덱스가 제일 깔끔합니다. CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops) WHERE category = 'manual'; 이렇게 조건부로 만들면 그 카테고리 안에서 바로 인덱스를 탑니다. 그리고 hnsw.ef_search 를 기본 40에서 100~200 정도로 올려서(SET hnsw.ef_search = 100;) 후보를 넉넉히 뽑게 하면 필터 뒤에도 LIMIT를 채울 확률이 올라갑니다.

아 그리고 인덱스 만든 뒤 ANALYZE docs; 한 번 돌렸는지도 확인하세요. 통계가 안 잡혀 있으면 플래너가 더 엉뚱하게 굽니다.
vvector_dain(guest)2개월 전
enable_seqscan off 로 보니까 Index Scan 뜨네요. 인덱스는 멀쩡했던 거였군요.

부분 인덱스 얘기 관련해서 하나만 더요. 저희는 카테고리가 수십 개라 카테고리마다 부분 인덱스를 만들면 인덱스가 너무 많아질 것 같은데 이건 괜찮은 방식인가요? ef_search는 어느 정도까지 올려도 되는지도 감이 안 옵니다.
김김영삼2개월 전
카테고리가 수십 개면 부분 인덱스는 안 맞습니다. 인덱스 수십 개 유지비용이 더 커요.

그 경우엔 pgvector 0.8부터 들어온 iterative index scan을 켜는 게 정답에 가깝습니다. SET hnsw.iterative_scan = 'relaxed_order'; 로 켜면 인덱스에서 후보를 조금씩 더 당겨오면서 필터를 통과한 게 LIMIT를 채울 때까지 반복해줍니다. 필터가 붙은 벡터검색을 위해 나온 기능이라 지금 상황에 딱이에요. 버전이 그보다 낮으면 업그레이드부터 검토하세요.

ef_search는 recall과 지연시간을 맞바꾸는 노브입니다. 40이 기본인데 필터가 있으면 100~200 정도가 무난하고, 그 이상 올려도 recall은 완만하게만 오르고 지연만 늘어요. 세션 단위(SET)로 잡고 실제 데이터로 recall 재보면서 필요한 최소값을 찾는 게 정석입니다. 무작정 크게 잡지 마세요.
vvector_dain(guest)2개월 전
iterative_scan 켜니까 필터 붙은 쿼리도 인덱스 타면서 700ms가 20ms대로 떨어졌습니다. 정확히 그 문제였네요. 감사합니다!
Ctrl+Enter로 등록