PostgreSQL·pgvector·Citus로 하이브리드 검색 구축

발행: (2026년 6월 8일 AM 01:22 GMT+9)
12 분 소요
원문: Dev.to

출처: Dev.to

검색은 겉보기엔 간단해 보입니다.

사용자가 다음과 같이 입력합니다:

short-range copper module

그리고 시스템이 올바른 제품을 반환하길 기대합니다. 경우에 따라 정확한 SKU까지도:

QDD-2Q200-CU3M

하지만 실제 제품 카탈로그, 특히 네트워킹 하드웨어처럼 기술적인 경우라면 검색이 훨씬 어려워집니다.

검색 대상은 단순히 제목만이 아닙니다. SKU, 브랜드, 사양, 카테고리, 설명, 호환성 메모, 데이터시트, 그리고 때로는 해당 업계 사람들만 이해하는 특이한 약어까지 모두 포함됩니다.

간단한 웹사이트라면 키워드 검색만으로도 충분합니다. 하지만 수십만 개의 제품을 보유한 진지한 카탈로그에서는 키워드 검색만으로는 한계가 생깁니다.

이때 하이브리드 검색이 흥미로워집니다.

이 글에서는 다음을 이용해 고성능 하이브리드 검색 시스템을 설계하는 방법을 설명하고자 합니다.

  • PostgreSQL
  • pgvector
  • 전체 텍스트 검색
  • HNSW 인덱스
  • Reciprocal Rank Fusion, 즉 RRF
  • 추후 확장을 위한 Citus

목표는 불필요하게 복잡한 아키텍처를 만드는 것이 아니라, 실용적이고 빠르며 유지보수가 쉬운 시스템을 구축하는 것입니다.


왜 키워드 검색만으로는 부족한가

전통적인 검색은 보통 단어 매칭으로 동작합니다.

예를 들어 사용자가 다음을 검색한다면:

Cisco Catalyst 9300 48-port PoE

키워드 검색은 Cisco, Catalyst, 9300, PoE와 같은 정확한 용어가 쿼리에 포함돼 있기 때문에 좋은 결과를 낼 수 있습니다.

하지만 사용자가 이렇게 검색한다면 어떨까요?

short range copper module

제품 설명에 정확히 그 단어들이 들어 있지 않을 수도 있습니다. 대신 direct attach cable, DAC와 같이 표현되거나 SKU와 기술 사양만 포함될 수도 있습니다.

순수 키워드 엔진은 단어가 정확히 일치하지 않으면 좋은 결과를 놓칠 수 있습니다.

이것이 벡터 검색이 유용한 주요 이유입니다. 벡터 검색은 단순히 정확한 단어만 보는 것이 아니라, 쿼리 뒤에 숨은 의미를 파악하려고 합니다.


pgvector가 제공하는 것

pgvector는 PostgreSQL이 임베딩(embedding)을 데이터베이스 안에 직접 저장하고 검색할 수 있게 해줍니다.

임베딩은 텍스트의 의미를 숫자 리스트 형태로 표현한 것입니다.

예를 들어 다음 텍스트:

Cisco 10G short range SFP transceiver

를 임베딩 모델에 넣으면 다음과 같은 벡터가 생성됩니다:

[0.021, -0.182, 0.441, ...]

실제 벡터는 보통 수백에서 수천 차원을 가집니다.

멋진 점은 PostgreSQL이 이제 이러한 벡터들을 비교해 사용자의 쿼리와 가장 가까운 제품을 찾을 수 있다는 것입니다.

즉, “이 정확한 단어를 포함하는 행은?”이라는 질문뿐 아니라
“이 쿼리와 의미적으로 가장 가까운 행은?”이라는 질문도 할 수 있게 됩니다.

이는 기술 카탈로그에 매우 강력합니다.


왜 나는 여전히 하이브리드 검색을 선호하는가

벡터 검색은 강력하지만 완벽하지는 않습니다. 가장 큰 약점 중 하나는 정확한 매칭을 놓친다는 점입니다.

예를 들어 누군가가 다음을 검색한다면:

Catalyst 9300 48-port PoE

벡터 검색은 의미적으로 비슷한 다음과 같은 제품들을 반환할 수 있습니다:

  • Catalyst 9300 24-port 모델
  • Catalyst 9400 모델
  • PoE가 없는 스위치
  • 유사한 Cisco 스위치

의미적으로는 이 제품들이 가깝지만, 구매자의 관점에서는 잘못된 결과가 될 수 있습니다. 사용자가 9300, 48-port, PoE를 명시했으니 이 세부 사항이 중요합니다.

따라서 나는 벡터 검색만으로 시스템을 구축하지 않을 것입니다. 더 나은 접근법은 하이브리드 검색입니다.

  • PostgreSQL 전체 텍스트 검색을 사용해 정확하고 어휘적인 매칭 수행
  • pgvector를 사용해 의미적 매칭 수행
  • 두 결과 리스트를 하나의 최종 순위로 병합

이렇게 하면 정밀도와 재현율을 모두 확보할 수 있습니다.


거리 측정(metric) 선택

벡터를 비교할 때는 거리 측정 방법이 필요합니다. pgvector는 여러 옵션을 지원하지만 가장 흔히 쓰이는 것은 다음과 같습니다.

  • 유클리드 거리(Euclidean distance)
  • 코사인 거리(Cosine distance)
  • 내적(Inner product)

텍스트 임베딩에서는 코사인 유사도가 기본값으로 많이 사용됩니다. 코사인은 벡터의 크기보다는 방향에 초점을 맞추기 때문입니다. 이는 긴 제품 설명과 짧은 사용자 쿼리 사이에 벡터 크기 차이가 크게 날 때 유리합니다.

pgvector에서 코사인 거리는 “ 연산자를 사용합니다:

ORDER BY embedding <-> $1

임베딩 모델이 이미 정규화된 벡터를 출력한다면, 내적(inner product)도 좋은 선택이 될 수 있습니다. 계산 비용이 보통 더 낮기 때문입니다.

하지만 가장 간단하고 안전한 구현을 원한다면, 나는 코사인 거리부터 시작해 벤치마크하는 것을 권합니다.


임베딩 모델 선택

임베딩 모델은 시스템 전체에서 가장 중요한 결정 중 하나입니다.

일반적인 모델은 일상 영어는 잘 이해하지만, 다음과 같은 기술 네트워킹 용어의 차이를 파악하지 못할 수 있습니다.

  • SMF vs. MMF
  • SFP vs. QSFP
  • DAC vs. AOC
  • 10G, 25G, 40G, 100G
  • 몇 글자만 다른 유사 SKU

이러한 카탈로그에는 기술 및 검색 작업에 특화된 모델을 선택하는 것이 좋습니다. 흥미로운 두 옵션은 다음과 같습니다.

  • Qwen 임베딩 모델
  • EmbeddingGemma 스타일의 경량 임베딩 모델

대형 모델은 의미 품질이 높지만 인프라 요구량도 큽니다. 실제 운영에서는 품질이 충분히 좋고 비용·운영이 쉬운 소형 모델이 더 현실적입니다.

제품 수가 약 40,000개 정도라면, 512차원 혹은 768차원 임베딩 모델로 시작해 품질을 측정한 뒤 필요에 따라 더 큰 모델로 전환하는 것이 현명합니다.

핵심 질문은 **“어떤 모델이 최고 점수를 받았는가?”**가 아니라, “어떤 모델이 적절한 지연 시간·스토리지·운영 비용 안에서 좋은 검색 품질을 제공하는가?” 입니다.


임베딩 전 제품 데이터 준비

많은 사람들이 저지르는 실수는 원시 설명만을 임베딩하는 것입니다. 전자상거래 검색에서는 이것만으로는 부족합니다.

제품에는 더 많은 유용한 컨텍스트가 있습니다.

  • SKU
  • 브랜드
  • 카테고리
  • 제품군
  • 기술 사양
  • 호환성
  • 짧은 설명
  • 긴 설명

임베딩을 만들기 전에 다음과 같은 정제된 텍스트 페이로드를 구성합니다:

Category: Optical Transceivers
Brand: Cisco
SKU: QDD-2Q200-CU3M
Product Family: QSFP-DD
Features: short range copper module, 200G, direct attach cable
Description: ...

이렇게 하면 임베딩 모델이 제품을 더 잘 이해하게 되고, 유사한 제품들이 벡터 공간에서 더 가깝게 클러스터링됩니다.

긴 설명이나 데이터시트의 경우, 무조건 500자씩 잘라내는 것은 피해야 합니다. 중요한 컨텍스트가 손실될 수 있기 때문입니다. 대신 오버랩이 있는 청크 방식을 사용합니다.

예시:

  • 청크 크기: 500~800 토큰
  • 오버랩: 100~150 토큰

오버랩은 청크 간 컨텍스트를 유지하는 데 도움이 됩니다.


간단한 PostgreSQL 스키마

이와 같은 시스템을 위한 간소화된 스키마 예시입니다:

CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE network_equipment (
    product_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    sku VARCHAR(100) UNIQUE NOT NULL,
    manufacturer VARCHAR(100) NOT NULL,
    category_id INT NOT NULL,
    price DECIMAL(10, 2),
    stock_quantity INT DEFAULT 0,
    description TEXT,

    search_vector tsvector GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(sku, '')), 'A') ||
        setweight(to_tsvector('english', coalesce(manufacturer, '')), 'B') ||
        setweight(to_tsvector('english', coalesce(description, '')), 'C')
    ) STORED,

    embedding vector(768)
);

CREATE INDEX idx_network_category
ON network_equipment (category_id);

CREATE INDEX idx_network_search
ON network_e
0 조회
Back to Blog

관련 글

더 보기 »