본문으로 건너뛰기

ActiveRecord와 neighbor 젬을 활용한 벡터 검색: 문서당 최대 결과 수 제한 및 성능 최적화

ActiveRecord neighbor vector search, with per-document max

작성자
발행일
2026년 02월 18일
https://bibwild.wordpress.com/2026/02/18/activerecord-neighbor-vector-search-with-per-document-max/

핵심 요약

  • 1 PostgreSQL의 pgvector와 neighbor 젬을 사용하여 RAG 시스템 구축 시 결과의 다양성을 확보하기 위해 문서당 청크 수를 제한하는 SQL 최적화 기법을 제안합니다.
  • 2 단순한 SQL 쿼리는 HNSW 인덱스를 활용하지 못해 성능 저하를 야기할 수 있으므로, CTE와 윈도우 함수를 결합하여 인덱스 효율을 극대화하는 구조를 설계했습니다.
  • 3 ActiveRecord의 Arel과 .with 메서드를 활용하여 기존의 벡터 거리 기반 관계를 래핑함으로써 유지보수 가능한 복합 쿼리 생성 메서드를 구현했습니다.

도입

본 글은 Ruby on Rails 환경에서 LLM 기반의 RAG(Retrieval-Augmented Generation) 시스템을 구축하며 겪은 기술적 도전 과제를 다룹니다. 특히 PostgreSQL의 pgvector 확장과 neighbor 젬을 활용할 때, 검색 결과의 다양성을 높이기 위해 특정 문서에서 추출되는 청크(Chunk)의 개수를 제한하는 방법을 탐구합니다. 저자는 성능 최적화를 위해 애플리케이션 레벨이 아닌 데이터베이스 레벨에서 이를 처리하고자 했으며, 이 과정에서 발생한 인덱스 활용 문제와 해결책을 공유합니다. 이는 대규모 벡터 데이터셋에서 효율적인 검색 시스템을 설계하려는 Rails 개발자들에게 중요한 실무적 통찰을 제공합니다.

1. 벡터 검색에서의 다양성 확보 필요성

LLM을 활용한 RAG 시스템에서 단순히 벡터 유사도(Similarity)만으로 상위 K개의 청크를 가져올 경우, 특정 문서 하나에서 대부분의 결과가 추출될 위험이 있습니다. 이는 정보의 편향성을 초래하므로, 검색 결과의 다양성을 위해 각 문서(Document)당 최대 청크 노출 개수를 제한(예: 문서당 최대 2개)하는 로직이 필요합니다. 저자는 이를 위해 PostgreSQL 레벨에서 효율적으로 필터링하는 방법을 모색했습니다.

2. SQL 성능 최적화의 난관과 인덱스 활용

초기에 LLM(ChatGPT, Claude)을 통해 생성한 SQL 쿼리는 기능적으로는 작동했으나 성능 면에서 심각한 결함이 있었습니다. - HNSW 인덱스 미사용: 쿼리가 복잡해지면서 PostgreSQL이 벡터 검색 전용 인덱스인 HNSW를 타지 않고 전체 테이블 스캔을 수행하는 문제가 발생했습니다. - 불필요한 전체 정렬: 인덱스를 활용하지 못하고 전체 데이터를 정렬한 후 필터링을 시도하여 응답 시간이 수 초 이상으로 늘어났습니다. 저자는 EXPLAIN ANALYZE를 통해 실행 계획을 분석하고, 인덱스를 강제로 활용할 수 있는 쿼리 구조를 재설계했습니다.

3. CTE와 Window Function을 활용한 해결책

최종적으로 선택된 방법은 CTE(Common Table Expressions)윈도우 함수(Window Function)를 결합하는 것입니다. - 내부 CTE (base): 우선 neighbor 젬을 사용하여 인덱스를 타는 최적화된 벡터 검색을 수행하고, inner_limit을 통해 필요한 것보다 넉넉한 양의 후보군을 먼저 추출합니다. - 중간 CTE (partitioned_ranked): 추출된 후보군 내에서 ROW_NUMBER() OVER (PARTITION BY document_id ORDER BY neighbor_distance)를 사용하여 문서별 순위를 매깁니다. - 최종 쿼리: 각 문서 내 순위가 설정된 max_per_document 이하인 결과만 필터링하여 반환합니다.

4. ActiveRecord 기반 구현 세부사항

저자는 이를 wrap_relation_for_max_per_interview라는 메서드로 모듈화했습니다. 이 메서드는 다음과 같은 특징을 가집니다. - Arel 활용: 복잡한 윈도우 함수 구문을 Arel.sql을 통해 안전하게 삽입합니다. - 메서드 체이닝: 기존 ActiveRecord::Relation 객체를 입력받아 CTE를 추가한 새로운 Relation을 반환하므로, Rails의 선언적 쿼리 작성 방식을 유지할 수 있습니다. - 유연성: inner_limit 파라미터를 통해 오버페칭(Over-fetching) 정도를 조절하여, 필터링 후에도 충분한 결과값이 남도록 보장합니다.

5. 아키텍처적 고려사항: SQL vs Ruby

데이터베이스 레벨의 처리가 성능상 유리할 수 있지만, 저자는 Ruby 레이어에서의 처리 가능성도 열어두었습니다. 만약 검색 결과를 가져온 뒤 별도의 Re-ranker 모델을 적용해야 한다면, SQL에서 엄격하게 제한하는 것보다 Ruby에서 더 많은 데이터를 가져와 유연하게 처리하는 것이 나을 수 있기 때문입니다. 따라서 시스템의 요구사항에 따라 적절한 위치를 선택하는 것이 중요합니다.

결론

결론적으로 SQL을 통한 문서당 결과 제한은 데이터베이스의 연산 능력을 활용하고 네트워크 전송량을 줄일 수 있는 강력한 방법입니다. 하지만 저자는 향후 교차 모델 재순위화(Re-ranking)와 같은 추가 로직이 필요할 경우 Ruby 레이어에서 처리하는 것이 더 유연할 수 있다는 점도 시사합니다. 이 글은 Rails 환경에서 고성능 벡터 검색 시스템을 설계하려는 개발자들에게 실질적인 쿼리 최적화 가이드와 아키텍처 선택에 대한 통찰을 제공하며, LLM 도구를 활용한 복잡한 SQL 생성 과정에서의 시행착오를 가감 없이 보여줍니다.

댓글0

댓글 작성

댓글 삭제 시 비밀번호가 필요합니다.

이미 계정이 있으신가요? 로그인 후 댓글을 작성하세요.

0/1000
정중하고 건설적인 댓글을 작성해 주세요.