별도 벡터 DB 없이 PostgreSQL 하나로 시맨틱,키워드 검색 통합하기

궁금하면 500원·2026년 2월 19일

데이터 저장하기

목록 보기
29/29
post-thumbnail

들어가며

RAG파이프라인을 구축하다 보면 "벡터 검색만으로 충분한가?", "PostgreSQL 하나로 이걸 다 처리할 수 있을까?"라는 질문에 반드시 부딪힙니다.
PGVector의 구조부터 BM25 공식의 수학적 해석, PostgreSQL의 ts_rank 동작 원리, 그리고 한국어 형태소 분석까지 대한내용을 포스팅 하게되었습니다.


1. PGVector란?

PGVector는 PostgreSQL에 벡터 연산 기능을 추가하는 Extension입니다.
별도 벡터 DB를 쓰면 되지 않냐고 생각할 수 있지만, PGVector의 진짜 강점은 기존 SQL 생태계와의 완전한 통합에 있습니다.

1-1. Extension이 추가하는 4가지 영역

① 연산자

-- L2 거리 (유클리디안 거리)
embedding1 <-> '[0.021, 0.034, ...]'

-- 코사인 유사도 거리
embedding1 <=> '[0.021, 0.034, ...]'

-- 내적 (Inner Product, 음수 반환)
embedding1 <#> '[0.021, 0.034, ...]'

② 함수

-- 벡터 간 L2 거리
SELECT l2_distance(embedding1, '[0.1, 0.2, 0.3]');

-- 코사인 거리 반환
SELECT cosine_distance(embedding1, '[0.1, 0.2, 0.3]');

-- 벡터 차원 수 확인
SELECT vector_dims(embedding1);

-- L2 정규화
SELECT l2_normalize(embedding1);

③ 집계 함수

-- 클러스터 중심점 계산 등에 활용
SELECT avg(embedding1) FROM vectortest;
SELECT sum(embedding1) FROM vectortest;

④ 타입 추가

embedding1 vector(1536)    -- 일반 float32 벡터
embedding2 halfvec(1536)   -- float16 반정밀도 (저장 공간 절반)
embedding3 bit(1536)       -- 이진 임베딩

halfvec은 메모리를 절반으로 줄이면서도 정밀도 손실이 크지 않아, 대용량 임베딩 저장 시 매우 실용적입니다.

1-2. HNSW vs IVFFlat

벡터 검색의 핵심은 ANN 인덱스입니다.
완전 탐색 없이 "충분히 가까운" 결과를 빠르게 찾습니다.

-- 테이블 생성
CREATE TABLE vectortest (
    id        BIGSERIAL PRIMARY KEY,
    text      TEXT,
    embedding1 vector(1536),
    text_search tsvector  -- 전문 검색용 컬럼 (후술)
);

-- HNSW 인덱스: 코사인 유사도 기준
CREATE INDEX idx_vectortest_embedding1_hnsw
ON vectortest
USING hnsw (embedding1 vector_cosine_ops);

-- HNSW 인덱스: L2 거리 기준
CREATE INDEX idx_vectortest_embedding1_hnsw_l2
ON vectortest
USING hnsw (embedding1 vector_l2_ops);

-- IVFFlat 인덱스: 메모리 효율 우선
CREATE INDEX idx_vectortest_embedding1_ivf
ON vectortest
USING ivfflat (embedding1 vector_cosine_ops)
WITH (lists = 100);
항목HNSWIVFFlat
검색 속도매우 빠름빠름
인덱스 구축 시간느림빠름
메모리 사용작음
정확도높음중간
실시간 삽입가능권장 안 함

실무에서는 HNSW를 기본으로 사용하고, 초기 수천만 건 대량 적재처럼 인덱스 구축 비용이 문제가 될 때만 IVFFlat을 고려합니다.

1-3. 유사도 검색 쿼리와 Execution Plan

-- 코사인 유사도 기준 상위 10개 검색
SELECT
    id,
    text,
    1 - (embedding1 <=> '[0.021, 0.034, -0.012, ...]') AS score
FROM vectortest
ORDER BY embedding1 <=> '[0.021, 0.034, -0.012, ...]'
LIMIT 10;

<=> 연산자는 코사인 거리를 반환하므로 1 - 거리로 유사도를 역산합니다.

인덱스 활용 여부는 반드시 확인하면 됩니다.

EXPLAIN ANALYZE
SELECT id, text, 1 - (embedding1 <=> '[0.021, ...]') AS score
FROM vectortest
ORDER BY embedding1 <=> '[0.021, ...]'
LIMIT 10;

정상 동작 시 아래와 같은 플랜이 나옵니다.

Index Scan using idx_vectortest_embedding1_hnsw on vectortest
  (cost=0.00..8.27 rows=10 width=548)
  (actual time=2.341..2.389 rows=10 loops=1)

Seq Scan이 나온다면 인덱스가 활용되지 않는 것입니다.
SET enable_seqscan = off;로 강제하거나, 인덱스 파라미터를 재검토해야 합니다.

1-4. SQL 통합의 진짜 강점

별도 벡터 DB에서는 어려운 복잡한 SQL 조합이 그대로 됩니다.

-- 필터 조건 + 벡터 검색 + 조인 결합
SELECT
    d.id,
    d.title,
    u.username,
    1 - (d.embedding <=> $1) AS similarity
FROM documents d
JOIN users u ON d.author_id = u.id
WHERE u.department = 'engineering'
  AND d.created_at > NOW() - INTERVAL '30 days'
ORDER BY d.embedding <=> $1
LIMIT 10;

트랜잭션, MVCC, VACUUM, WAL/Replication이 벡터 테이블에도 동일하게 적용됩니다.
벡터 데이터도 일반 RDB 데이터와 동일한 수준의 내구성과 일관성을 보장받습니다.


2. CTE

2-1. CTE란?

CTE는 쿼리 안에서만 존재하는 유사 임시 뷰입니다.
복잡한 서브쿼리를 이름을 붙여 선형적으로 분리할 수 있어 가독성이 극적으로 향상됩니다.

WITH
    A AS (SELECT id, text, embedding FROM vectortest WHERE category = 'tech'),
    B AS (SELECT user_id, preference_vector FROM user_profiles WHERE active = true)
SELECT
    a.id,
    a.text,
    1 - (a.embedding <=> b.preference_vector) AS score
FROM A a
JOIN B b ON a.author_id = b.user_id
WHERE b.user_id = 42
ORDER BY score DESC
LIMIT 10;

2-2. 버전별 동작 차이

PostgreSQL v11까지: CTE는 무조건 먼저 물리화된 임시 뷰로 실행됩니다.
옵티마이저가 CTE 내부에 외부 조건을 밀어 넣지 못합니다.

-- v11: A 전체(100만 건)를 메모리에 올린 뒤 id=1 필터링
WITH A AS (
    SELECT * FROM vectortest  -- 전체 스캔 강제 발생
)
SELECT * FROM A WHERE id = 1;

이것이 v11까지 CTE를 "수동 최적화 도구"로 쓰던 이유입니다. A, B가 서로 상관관계가 없다면 병렬 실행이 가능했고, 개발자가 의도적으로 물리 임시 뷰를 만들어 중간 결과를 재사용하는 최적화 전략이 가능했습니다.

PostgreSQL v12 이후: 옵티마이저가 인라인 가능 여부를 스스로 판정합니다.

-- 강제 물리화 (v11 동작과 동일하게 만들고 싶을 때)
WITH A AS MATERIALIZED (
    SELECT * FROM vectortest
)
SELECT * FROM A WHERE id = 1;

-- 강제 인라인 (옵티마이저에게 최적화 자유를 줌)
WITH A AS NOT MATERIALIZED (
    SELECT * FROM vectortest
)
SELECT * FROM A WHERE id = 1;

2-3. v12 이후에도 CTE를 써야 하는 이유

성능 측면에서는 서브쿼리와 거의 동일해졌지만, 코드 품질 측면에서는 비교가 되지 않습니다.

-- CTE 없이 작성한 코드: 중첩의 지옥
SELECT final.id, final.score
FROM (
    SELECT r.id, r.score, u.tier
    FROM (
        SELECT id, 1 - (embedding <=> $1) AS score
        FROM vectortest
        WHERE category = 'tech'
    ) r
    JOIN (
        SELECT user_id, tier FROM user_profiles WHERE active = true
    ) u ON r.id = u.user_id
) final
WHERE final.tier = 'premium'
ORDER BY final.score DESC;

-- CTE로 작성한 동일한 쿼리: 위에서 아래로 읽히는 선형 흐름
WITH
    similarity_results AS (
        SELECT id, 1 - (embedding <=> $1) AS score
        FROM vectortest
        WHERE category = 'tech'
    ),
    premium_users AS (
        SELECT user_id, tier
        FROM user_profiles
        WHERE active = true AND tier = 'premium'
    )
SELECT r.id, r.score
FROM similarity_results r
JOIN premium_users u ON r.id = u.user_id
ORDER BY r.score DESC;

또한 물리 뷰는 내부에 파라미터를 받을 수 없지만, CTE는 동적 쿼리에서도 뷰의 편의성을 그대로 제공합니다.
RAG 파이프라인의 검색 쿼리를 CTE로 구성하면 로직 분리, 단위 테스트, 디버깅이 모두 수월해집니다.


3. BM25 공식을 수학적으로 이해하기

3-1. 왜 BM25가 필요한가

벡터 검색은 의미적 유사도를 잘 찾지만 정확한 키워드 매칭에서는 BM25가 강합니다.
하이브리드 검색의 필수 구성 요소입니다.

단순 TF의 문제를 먼저 봅시다.

문서내용
A"고양이 사료 추천합니다. 건식 사료가 좋아요."
B"고양이 고양이 고양이 고양이 고양이 먹이 먹이"

쿼리: "고양이 사료"

단순 카운트는 "고양이" 5회인 B가 이깁니다.
하지만 실제로는 A가 훨씬 관련성이 높습니다.
BM25는 이 문제를 세 가지 메커니즘으로 해결합니다.

3-2. BM25 공식 상세 해설

BM25(D,Q)=tQIDF(t)f(t,D)(k1+1)f(t,D)+k1(1b+bDavgdl)BM25(D, Q) = \sum_{t \in Q} IDF(t) \cdot \frac{f(t,D) \cdot (k_1 + 1)}{f(t,D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{avgdl}\right)}

파라미터 정의

기호의미
f(t, D)문서 D에서 토큰 t의 등장 횟수 (TF)
\|D\|문서 길이 (= 토큰 수)
avgdl전체 문서의 평균 토큰 수
k1Saturation 강도 (보통 1.2~2.0)
b길이 정규화 강도 (보통 0.75)

TF 부분의 포화 억제 동작

|D| = avgdl, k1 = 2로 단순화하면 TF 항은 아래처럼 됩니다.

TF_bm25 = f(t,D) × 3 / (f(t,D) + 2)

실제로 계산해보면 포화 효과가 명확하게 보입니다.

f = 1  →  1×3/(1+2)  = 1.00
f = 3  →  3×3/(3+2)  = 1.80
f = 5  →  5×3/(5+2)  = 2.14
f = 10 →  10×3/(10+2)= 2.50
f = 50 →  50×3/(50+2)= 2.88   ← TF 10 이후 거의 의미 없어짐
f = 100→  100×3/(100+2)= 2.94

k1, b, |D|가 커질수록 포화가 더 빨리 옵니다.
|D|가 평균보다 훨씬 크면 분모가 커지면서 TF 가중치가 자연스럽게 억제됩니다.
이것이 길이 정규화의 핵심 메커니즘입니다.

IDF 부분

IDF(t)=log(1+Ndf(t)+0.5df(t)+0.5)IDF(t) = \log\left(1 + \frac{N - df(t) + 0.5}{df(t) + 0.5}\right)

  • N: 전체 문서 수
  • df(t): 토큰 t가 등장한 문서 수

IDF의 동작 특성을 정리하면 이렇습니다.

토큰 등장 비율 8% 이하  → IDF 1.0 이상 (고점 가중치 부여)
토큰 등장 비율 50% 이상 → IDF 음수 진입 (패널티 발생)
토큰 등장 비율 100%     → IDF = -0.xx (완전 불용어 수준)

"은/는/이/가", "이다", "아니다"는 거의 모든 문서에 등장하므로 IDF ≈ 0에 가깝거나 음수가 됩니다.
반면 "트랜스포머", "HNSW", "RRF" 같은 전문 용어는 IDF가 높아 검색 결과에 큰 영향을 미칩니다.

3-3. 토큰 수에 따른 IDF 영향력 변화

BM25(D,Q)=tQIDF(t)×TFbm25(t,D)BM25(D, Q) = \sum_{t \in Q} IDF(t) \times TF_{bm25}(t, D)

토큰 1개로 검색

BM25 = IDF(t1) × TF_bm25(t1, D)

IDF가 지배합니다. "희귀한 단어를 포함한 문서"가 압도적으로 유리합니다.

토큰 10개로 검색

BM25 = Σ(10개) IDF(ti) × TF_bm25(ti, D)

이제 중요한 것은 커버리지입니다. 10개 중 8개 매칭은 10개 중 3개 매칭을 점수로 압도합니다.
그래프를 보면 파란선은 토큰이 늘어날수록 계속 우상향하지만, 오렌지선은 평균에 수렴합니다.

토큰 20개 이상

IDF의 영향력은 사실상 희석되고 커버리지가 지배합니다.
긴 문서일수록 커버리지가 높을 확률이 있으므로, 이때는 길이 보정 파라미터 b의 영향력이 결정적으로 중요해집니다.

토큰 수 증가
  → IDF 영향력 희석
  → 커버리지 영향력 증가
  → 길이 보정(b) 중요도 증가

임베딩 검색과의 역할 분담 관점에서 보면:

임베딩 검색은 보통 문장 전체나 문단이 쿼리로 들어오므로 토큰 수가 충분히 많습니다.
따라서 IDF의 역할은 이미 임베딩 벡터 공간이 담당하고, BM25에서는 TF만으로도 충분합니다.
즉 하이브리드 검색에서 BM25의 IDF는 임베딩이 보완해주는 구조가 자연스럽게 형성됩니다.


4. PostgreSQL의 ts_rank와 ts_rank_cd

4-1. ts_rank 공식

ts_rank(D,Q)=tQwtft,Dts\_rank(D, Q) = \sum_{t \in Q} w_t \cdot f_{t,D}

PostgreSQL의 ts_rank는 가중치(w_t) × 단어 빈도(f_t,D)의 합입니다. BM25와 달리 saturation이 없어 등장 횟수에 선형적으로 반응한다는 것이 중요한 차이점입니다.

4-2. 구역별 가중치

PostgreSQL은 문서를 4개 구역으로 나눠 가중치를 달리 줄 수 있습니다.

구역기본 가중치적합한 내용
A1.0헤드라인, 제목, 핵심 키워드, 태그
B0.4부제, 요약문, 리드 문단, H1/H2
C0.2본문, 일반 텍스트, 설명글
D0.1주석, 댓글, 부록, 참고문헌

구역 가중치 적용 실전 예시

구역 정보는 데이터를 입력할 때 직접 넣어야 합니다. 아무것도 안 하면 전부 D 가중치(0.1)입니다.

-- vectortest 테이블에 text_search 컬럼이 있다고 가정
-- 제목(A)과 본문(D)을 구분해서 tsvector로 저장
UPDATE vectortest
SET text_search =
    -- 1단계: 제목을 벡터로 만들고 'A' 딱지를 붙인다
    setweight(to_tsvector('korean', title), 'A')
    ||  -- (이어붙이기 연산자)
    -- 2단계: 본문을 벡터로 만들고 'D' 딱지(기본값)를 붙인다
    setweight(to_tsvector('korean', content), 'D');

-- 가중치 배열을 명시적으로 지정하는 방법
-- {D가중치, C가중치, B가중치, A가중치} 순서
SELECT ts_rank(text_search, query, 0, '{0.1, 0.2, 0.4, 1.0}')
FROM vectortest,
     to_tsquery('korean', '인공지능 & 학습') query
WHERE text_search @@ query;

실제 파이프라인에서는 ETL 시점에 구역별로 분리된 컬럼을 setweight로 합쳐두고, 인덱스를 GIN으로 걸어두는 패턴이 일반적입니다.

-- GIN 인덱스 생성 (전문 검색 필수)
CREATE INDEX idx_vectortest_text_search
ON vectortest USING GIN (text_search);

4-3. 밀집도 반영

ts_rank가 단순 빈도 기반이라면, ts_rank_cd쿼리 토큰들이 얼마나 가깝게 뭉쳐 있는지를 반영합니다.
같은 단어들이 등장하더라도 뿔뿔이 흩어진 문서보다 한 구절에 집중된 문서가 더 높은 점수를 받습니다.

구간 분할 알고리즘

질의 토큰 = A, B, C 일 때

문서: ....[A v v B]....[A v B v C]....
         구간1(길이4)    구간2(길이5)

전체를 하나의 구간으로 보면:

3(ABC) / 13(전체길이) = 0.23   ← 낮은 점수

두 구간으로 최적 분할하면:

구간1: 2(AB) / 4 = 0.50
구간2: 3(ABC) / 5 = 0.60
합산: 1.10                      ← 훨씬 높은 점수!

ts_rank_cd는 가능한 모든 분할 조합 중 가장 높은 점수를 내는 분할을 선택합니다.

가중치까지 적용한 계산

토큰 가중치: A=1.2, B=0.5, C=0.8

ts_rank_cd = (1.2 + 0.5) / 4  +  (1.2 + 0.5 + 0.8) / 5
           =       0.425        +          0.500
           =       0.925
-- ts_rank_cd 사용 예시
SELECT
    id,
    title,
    ts_rank_cd(
        text_search,
        to_tsquery('korean', '인공지능 & 모델 & 학습')
    ) AS rank_cd
FROM vectortest
WHERE text_search @@ to_tsquery('korean', '인공지능 & 모델 & 학습')
ORDER BY rank_cd DESC
LIMIT 10;

4-4. BM25와 PostgreSQL ts_rank 비교표

기능BM25ts_rankts_rank_cd
TF 포화 억제● 수식 명시X 선형 반응● 밀집도로 간접 구현
IDF (희귀성)● 지원X 미지원X 미지원
문서 길이 정규화● 수식 내 포함● 옵션 제공● 옵션 제공
토큰 간 근접도X 미고려X 미고려● 핵심 기능
구역별 가중치X A/B/C/D● A/B/C/D● A/B/C/D

PostgreSQL은 IDF를 기본 제공하지 않기 때문에, 완전한 BM25가 필요하다면 pg_bm25 익스텐션을 고려해야 합니다.

4-5. 문서 길이 보정 옵션

-- Opt 1: Score / (1 + log(Length)) — 완만한 로그 감쇄
SELECT ts_rank_cd(text_search, query, 1) FROM vectortest, query;

-- Opt 2: Score / Length — 급격한 역함수 감쇄
SELECT ts_rank_cd(text_search, query, 2) FROM vectortest, query;

-- 옵션 조합도 가능 (비트 OR)
SELECT ts_rank_cd(text_search, query, 1|4) FROM vectortest, query;
-- 1: 1+log(Length), 4: Length/mean_length, 8: unique_word_count

읽어야 할 핵심

Opt 1 그래프를 보면, 40개 토큰을 매칭한 문서는 문서 길이가 2000이 되어도 여전히 0.9 수준의 점수를 유지합니다.
긴 문서에서도 커버리지의 가치를 보존합니다.

반면 Opt 2 그래프를 보면, 40개 토큰 매칭이더라도 문서 길이가 250을 넘는 순간 점수가 거의 0에 수렴합니다.
짧은 문서에서 정확히 일치하는 경우에만 의미 있는 점수를 줍니다.

어떤 상황에 어떤 옵션을?

Opt 1 (로그 감쇄): Score / (1 + log(Length))
  → 긴 문서에서도 커버리지 우선
  → 기사, 논문, 아티클 등 비교적 긴 문서
  → RAG에서 청크 크기를 500~1000 토큰으로 잡을 때
  → "긴 글에서 많은 관련 토큰을 찾아라"

Opt 2 (선형 감쇄): Score / Length
  → 짧은 문서에서 정확 매칭 우선
  → 커버리지가 일정 길이 이후 무의미해짐 → 간접적 IDF 효과
  → 제목, 상품명, FAQ, 법령 각조, 계약서 각항
  → RAG에서 청크를 50~100 토큰으로 극단적으로 짧게 가져갈 때

5. 형태소 분석기와 토큰화

5-1. 토큰화의 전체 파이프라인

원문: "인공지능 모델이 가셨습니다."
  ↓
[1단계] UTF 토크나이저
  공백, 괄호, 마침표 등 분리자 기준 분리
  → ["인공지능", "모델이", "가셨습니다"]
  ↓
[2단계] 형태소 분석 (Mecab-ko 등 외부 익스텐션)
  → 인공지능/NNG, 모델/NNG, 이/JKS, 가시/VV, 었/EP, 습니다/EF
  ↓
[3단계] Stemming / Lemmatization (원형 전환)
  → 인공지능, 모델, 가다

라틴계 언어는 PostgreSQL 내장 파서로 처리 가능하지만, 한국어, 일본어, 중국어는 외부 익스텐션에 위임해야 합니다.

5-2. Mecab-ko 출력 해석

입력: 가셨습니다
출력: VV+EP+EF,*,F,가셨습니다,Inflect,VV,EP,가시/VV/*+었/EP/*+습니다/EF/*
태그의미
VV동사 (Verb)
EP선어말 어미 (Pre-final Ending)
EF종결 어미 (Final Ending)
NNG일반 명사
JKS주격 조사

PostgreSQL의 한계: 이 문법적 의미를 보존하지 못합니다.
"먹(VV, 동사)"과 "먹(NNG, 명사 — 곰팡이)"를 구분하지 못하고 단순히 "먹"이라는 토큰으로만 처리됩니다.
이것이 고품질 한국어 검색을 위해 전문 분석기가 필요한 이유입니다.

5-3. 고급 텍스트 처리 전략

완성도 높은 검색을 위한 추가 처리 레이어들입니다.

① 불용어 제거

-- unaccent 익스텐션 + 커스텀 사전으로 불용어 설정
CREATE TEXT SEARCH DICTIONARY korean_stop (
    Template = pg_catalog.simple,
    Stopwords = korean_stopwords  -- 파일명: korean_stopwords.stop
);
-- 파일 내용: 은 는 이 가 을 를 의 에 어 아 하다 이다 있다 되다

② 동의어 전환

CREATE TEXT SEARCH DICTIONARY ai_synonyms (
    Template = synonym,
    Synonyms = ai_terms
-- ai_terms.syn 파일:
-- 인공지능 AI
-- 머신러닝 ML
-- 딥러닝 DL
-- 자연어처리 NLP
);

③ 복합 명사 처리 전략

입력: "인공지능모델학습데이터"

전략 1 (완전 분리):   인공지능, 모델, 학습, 데이터
전략 2 (의미 단위):   인공지능, 모델학습, 데이터
전략 3 (N-gram):      인공지능모, 공지능모델, 지능모델학, ...

N-gram은 recall을 높이지만 인덱스 크기와 검색 노이즈가 증가합니다.
도메인 특화 사전을 만들기 어려운 경우 타협점으로 사용합니다.

④ 쿼리 리라이팅 (Query Rewriting)

"서울대 컴공"         → "서울대학교 컴퓨터공학과"
"삼성폰"             → "삼성전자 갤럭시 스마트폰"
"심근경색"           → "심근경색 myocardial infarction MI"

이는 PostgreSQL 레벨보다 애플리케이션 레벨이나 Elasticsearch의 synonym_graph 토크나이저로 구현하는 경우가 많습니다.
단, PostgreSQL에서도 ts_rewrite() 함수로 제한적으로 지원합니다.

-- ts_rewrite: 쿼리 자체를 다른 쿼리로 재작성
SELECT ts_rewrite(
    to_tsquery('korean', '서울대'),
    'SELECT to_tsquery(''korean'', ''서울대''),
            to_tsquery(''korean'', ''서울대학교 | 서울대'')'
);
-- 결과: '서울대학교' | '서울대'

6. RAG 하이브리드 검색 완성

위의 모든 내용을 통합한 실전 쿼리입니다.

-- 테이블 설계
CREATE TABLE documents (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    category    TEXT,
    author_id   BIGINT,
    created_at  TIMESTAMPTZ DEFAULT NOW(),
    embedding   vector(1536),                          -- 벡터 검색용
    text_search tsvector GENERATED ALWAYS AS (        -- 전문 검색용
        setweight(to_tsvector('korean', title),   'A') ||
        setweight(to_tsvector('korean', content), 'D')
    ) STORED
);

-- 인덱스 생성
CREATE INDEX idx_doc_embedding_hnsw
    ON documents USING hnsw (embedding vector_cosine_ops);
CREATE INDEX idx_doc_text_search_gin
    ON documents USING GIN (text_search);

-- 하이브리드 검색 쿼리 (RRF 융합)
WITH
    -- 벡터 유사도 검색 (시맨틱)
    vector_results AS (
        SELECT
            id,
            ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank
        FROM documents
        WHERE category = $3  -- 필터링 가능
        ORDER BY embedding <=> $1::vector
        LIMIT 60
    ),
    -- 키워드 검색 (BM25 근사: ts_rank_cd + 로그 정규화)
    keyword_results AS (
        SELECT
            id,
            ROW_NUMBER() OVER (
                ORDER BY ts_rank_cd(text_search, query, 1) DESC
            ) AS rank
        FROM documents,
             to_tsquery('korean', $2) query
        WHERE text_search @@ query
        ORDER BY ts_rank_cd(text_search, query, 1) DESC
        LIMIT 60
    ),
    -- RRF (Reciprocal Rank Fusion): k=60이 일반적인 기본값
    rrf_scores AS (
        SELECT
            COALESCE(v.id, k.id) AS id,
            COALESCE(1.0 / (60 + v.rank), 0.0) AS vector_rrf,
            COALESCE(1.0 / (60 + k.rank), 0.0) AS keyword_rrf
        FROM vector_results v
        FULL OUTER JOIN keyword_results k ON v.id = k.id
    )
SELECT
    d.id,
    d.title,
    LEFT(d.content, 200) AS preview,
    r.vector_rrf + r.keyword_rrf AS rrf_score,
    r.vector_rrf,
    r.keyword_rrf
FROM rrf_scores r
JOIN documents d ON r.id = d.id
ORDER BY rrf_score DESC
LIMIT 10;

7. 전체 아키텍처 정리

사용자 쿼리
    │
    ├─► 임베딩 생성 (OpenAI / 로컬 모델)
    │       └─► vector_results (HNSW 인덱스)
    │
    ├─► 형태소 분석 + 불용어 제거 + 동의어 전환
    │       └─► keyword_results (GIN 인덱스 + ts_rank_cd)
    │
    └─► RRF 융합
            └─► 최종 TOP-K 문서 → LLM 컨텍스트 주입

마치며

이 글에서 다룬 내용을 핵심만 다시 정리합니다.

PGVector는 HNSW 인덱스와 코사인 유사도 연산자로 시맨틱 검색을 SQL에 통합합니다.
트랜잭션, 조인, CTE, WAL까지 모두 벡터 테이블에 그대로 적용되는것을 배웠습니다.

CTE는 v12 이후 성능보다 유지보수성이 주된 이유가 됩니다.
복잡한 RAG 검색 쿼리를 선형적으로 읽히게 만들고, 물리 뷰와 달리 동적 파라미터를 받을 수 있습니다.

BM25의 핵심은 "토큰 수가 늘어날수록 IDF의 영향력이 희석되고 커버리지가 지배한다"는 것입니다.
임베딩 검색이 IDF 역할을 이미 담당하므로, 하이브리드 검색에서 BM25는 TF만으로도 충분합니다.

ts_rank vs ts_rank_cd에서 ts_rank는 선형 TF 기반이고, ts_rank_cd는 밀집도로 포화를 간접 구현합니다.
길이 보정 옵션은 청크 크기에 따라 로그 감쇄, 긴 문서과 선형 감쇄, 짧은 문서를 전략적으로 선택해야 합니다.

한국어 형태소 분석은 반드시 외부 익스텐션이 필요하며, 불용어 제거 → 동의어 전환 → 복합 명사 처리 → 쿼리 리라이팅까지 단계적으로 완성도를 높여가야 합니다.

PostgreSQL + PGVector는 단순한 "벡터 DB의 저렴한 대안"이 아닙니다.
벡터 검색, 전문 텍스트 검색, 복잡한 비즈니스 로직을 하나의 SQL 트랜잭션으로 묶을 수 있는 강력한 플랫폼입니다.

profile
그냥 코딩할래요 재미있어요

0개의 댓글