- Published on
PostgreSQL pgvector RAG 검색 품질 급락 원인과 해결 체크리스트
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG를 PostgreSQL + pgvector로 빠르게 붙였는데, 어느 날부터 검색 결과가 “갑자기 멍청해진” 경험이 있나요? 같은 쿼리인데 Top-K에 엉뚱한 문서가 올라오고, LLM 답변은 근거를 못 찾거나 환각이 늘어납니다.
대부분의 경우 문제는 LLM이 아니라 벡터 검색 품질(Recall@K) 급락에서 시작합니다. 특히 pgvector에서는 다음 3가지가 겹치면 품질이 확 떨어집니다.
- cosine vs inner product 선택을 잘못함(혹은 임베딩 모델의 “권장 스코어링”과 불일치)
- 벡터 정규화 누락/중복(저장 시, 쿼리 시 한쪽만 정규화)
- HNSW/IVFFlat 인덱스 파라미터가 데이터/쿼리 패턴과 안 맞음(근사 검색의 탐색량이 부족)
이 글은 “왜 떨어지는지”를 수학적으로 짧게 정리하고, 실전에서 Recall@K를 다시 끌어올리는 체크리스트를 제공합니다.
1) 먼저 정의부터: Recall@K가 떨어졌다는 건 무슨 뜻인가
RAG에서 검색 품질을 가장 현실적으로 보는 지표는 보통 Recall@K입니다.
- 정답 문서(혹은 정답 chunk)가 Top-K 결과 안에 들어오면 1, 아니면 0
- 여러 쿼리로 평균을 내면 모델/인덱스/파라미터 변경의 영향을 빠르게 비교 가능
현업에서 자주 보는 증상:
- K=5에서는 거의 못 찾고 K=50으로 올리면 그나마 찾음 → 근사 검색 탐색량 부족 또는 거리함수/정규화 문제
- 특정 도메인(짧은 쿼리, 숫자 포함, 코드 포함)에서만 급락 → 임베딩 전처리/토크나이저/정규화 불일치 가능
- 재색인 이후 급락 → 인덱스 파라미터 초기값이 바뀌었거나, 데이터 분포가 달라졌는데 파라미터를 그대로 씀
2) cosine vs inner product vs L2: 무엇을 써야 하는가
pgvector는 연산자를 통해 거리/유사도를 선택합니다.
<->: L2 distance (유클리드)<#>: (negative) inner product 계열(버전에 따라 부호 주의)<=>: cosine distance
여기서 가장 흔한 사고는 임베딩 모델이 “코사인 유사도 기준”인데 inner product로 검색하거나, 반대로 정규화된 벡터에 L2를 쓰면서 스코어 해석을 잘못하는 경우입니다.
핵심 관계식: 정규화하면 cosine ≈ inner product
벡터를 L2 정규화해서 (|x|=1), (|y|=1)로 만들면:
- cosine similarity: (\cos(x,y) = x \cdot y)
- L2 distance: (|x-y|^2 = 2 - 2(x\cdot y))
즉, 정규화된 벡터라면 cosine/inner product/L2는 단조 관계라 랭킹이 거의 동일해집니다.
반대로 말하면:
- 정규화가 안 된 상태에서 inner product를 쓰면 벡터의 “방향”뿐 아니라 “길이(노름)”가 점수에 섞여 들어가 랭킹이 망가질 수 있습니다.
- 어떤 모델은 의도적으로 벡터 노름에 의미를 담기도 합니다(희귀하지만 존재). 이 경우 무작정 정규화하면 성능이 떨어질 수 있습니다.
실전 권장
- 대부분의 범용 텍스트 임베딩은 cosine 또는 정규화 + inner product가 안정적입니다.
- 팀 내에서 “우리는 코사인으로 한다”처럼 원칙을 하나로 고정하고, 저장/쿼리/인덱스까지 일관되게 맞추세요.
3) 검색 품질 급락 1순위: 벡터 정규화 불일치
정규화 이슈는 보통 아래 형태로 발생합니다.
- 문서 벡터는 정규화했는데 쿼리 벡터는 정규화 안 함
- 쿼리만 정규화하고 문서는 안 함
- 파이프라인 중간에서 두 번 정규화(큰 문제는 아니지만, 다른 전처리와 섞이면 디버깅이 어려움)
체크 방법: 노름 분포를 SQL로 바로 확인
-- 벡터 노름(길이) 분포 확인
SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY l2_norm(embedding)) AS p50,
percentile_cont(0.9) WITHIN GROUP (ORDER BY l2_norm(embedding)) AS p90,
max(l2_norm(embedding)) AS max_norm
FROM documents;
- p50
p90이 1 근처로 촘촘하면(예: 0.991.01) 정규화된 가능성이 높습니다. - 분포가 넓으면 정규화가 안 되었거나, 모델 자체가 노름을 다양하게 뱉는 것입니다.
Python에서 정규화 예시(저장/쿼리 동일 적용)
import numpy as np
def l2_normalize(v: np.ndarray, eps: float = 1e-12) -> np.ndarray:
v = v.astype(np.float32)
return v / (np.linalg.norm(v) + eps)
# 저장 시
doc_vec = l2_normalize(np.array(doc_embedding))
# 쿼리 시
q_vec = l2_normalize(np.array(query_embedding))
흔한 함정
- 임베딩 API/라이브러리가 이미 정규화된 벡터를 반환하는데, 팀원이 모르고 또 정규화/스코어링을 바꿔버림
- 배치 적재 파이프라인(ETL)과 온라인 쿼리 서비스가 서로 다른 코드 경로를 사용
임베딩 생성이 외부 API에 의존한다면, 레이트리밋으로 재시도 로직이 꼬여 문서 일부가 다른 모델/버전으로 섞여 들어가는 경우도 있습니다. 이런 운영 이슈는 검색 품질을 “서서히” 또는 “특정 시점부터” 망가뜨립니다. 운영 관점의 방어는 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기 같은 방식으로 모델/버전/재시도 정책을 고정하는 것이 좋습니다.
4) HNSW vs IVFFlat: “근사 검색”이 Recall을 갉아먹는 방식
정확 검색(브루트포스)은 느리지만 품질이 안정적입니다. 인덱스(HNSW/IVFFlat)는 빠르지만 탐색을 덜 하면 Recall@K가 떨어집니다.
4.1 HNSW에서 Recall을 좌우하는 것들
HNSW는 그래프 기반 근사 검색입니다. 핵심 파라미터는 다음입니다.
m: 그래프의 연결도(메모리/빌드시간 증가, 보통 16~48)ef_construction: 인덱스 구축 품질(높을수록 품질↑, 빌드시간↑)ef_search: 검색 시 탐색 폭(높을수록 Recall↑, 지연↑)
인덱스 생성 예시
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 32, ef_construction = 200);
쿼리 시 ef_search 조정(세션 단위)
SET hnsw.ef_search = 80;
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;
증상 기반 가이드
- Recall@10이 낮고, K를 50으로 올리면 개선 →
ef_search부터 올려보세요. - 재색인 이후만 품질 저하 →
m,ef_construction이 낮게 잡혔을 가능성.
4.2 IVFFlat에서 Recall을 좌우하는 것들
IVFFlat은 클러스터(리스트)로 나누고 일부만 탐색합니다.
lists: 클러스터 개수(너무 적으면 대충 뭉개짐, 너무 많으면 학습/메모리/탐색 비용 증가)probes: 검색 시 탐색할 리스트 수(높을수록 Recall↑, 지연↑)
인덱스 생성 예시
CREATE INDEX CONCURRENTLY documents_embedding_ivfflat
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 200);
쿼리 시 probes 조정
SET ivfflat.probes = 10;
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;
중요: IVFFlat은 데이터가 충분히 쌓인 뒤(분포가 안정된 뒤) 만들수록 좋습니다. 데이터가 계속 늘어나는 상황에서 빈번히 재구축 없이 운영하면 품질이 흔들릴 수 있습니다.
5) “품질이 급락했다”를 재현 가능하게 만드는 최소 평가 하네스
튜닝은 감으로 하면 끝이 없습니다. 최소한 아래 정도는 자동화하세요.
5.1 정답셋 만들기(현업식)
- 최근 실제 사용자 질문 N개를 샘플링
- 각 질문에 대해 “정답 문서/정답 chunk id”를 1~3개 라벨링(완벽할 필요 없음)
5.2 Recall@K 측정 스크립트(개념 코드)
from collections import defaultdict
def recall_at_k(retrieved_ids, gold_ids, k):
topk = set(retrieved_ids[:k])
return 1.0 if any(g in topk for g in gold_ids) else 0.0
# queries: [{"q": "...", "gold": [12, 99]}]
# search(q) -> [doc_id1, doc_id2, ...]
def evaluate(queries, search, ks=(5,10,20)):
scores = defaultdict(float)
for item in queries:
retrieved = search(item["q"])
for k in ks:
scores[k] += recall_at_k(retrieved, item["gold"], k)
for k in ks:
scores[k] /= len(queries)
return dict(scores)
이걸로 다음 변경을 “숫자”로 비교하세요.
- cosine ↔ inner product
- 정규화 on/off
- HNSW
ef_search40/80/120 - IVFFlat
probes5/10/20
6) 실전 튜닝 체크리스트: Recall@K 올리는 순서
아래 순서대로 하면 헛삽을 크게 줄일 수 있습니다.
6.1 0단계: 브루트포스로 기준선 만들기
인덱스를 잠깐 무시하고(혹은 작은 샘플 테이블로) 정확 검색 기준선을 확보하세요.
- 기준선 Recall@K가 낮으면: 임베딩/청킹/전처리/정답셋 자체를 의심
- 기준선은 높은데 인덱스에서만 낮으면: HNSW/IVFFlat 파라미터 문제
6.2 1단계: 거리함수와 정규화 일관성 고정
- 문서/쿼리 모두 정규화할지 말지 결정
- 연산자(
<=>,<#>,<->)를 결정 - 결정한 규칙을 저장 파이프라인 + 검색 API + 인덱스 operator class까지 일치
예: “cosine로 간다”면
- ORDER BY:
embedding <=> $1 - 인덱스:
vector_cosine_ops
6.3 2단계: HNSW라면 ef_search부터 올려라
- 지연이 허용되는 범위에서
ef_search를 40 → 80 → 120으로 올리며 Recall 변화를 측정 - 일반적으로 “품질 급락”은
ef_search가 너무 낮을 때 가장 빠르게 드러납니다.
6.4 3단계: IVFFlat이라면 probes를 올리고, lists를 재검토
ivfflat.probes를 올리면 Recall이 선형에 가깝게 회복되는 경우가 많습니다.- 그래도 안 오르면
lists가 너무 작거나 너무 커서 분포를 못 잡는 경우가 있습니다.
경험칙(절대값 아님):
- 문서 수가 수십만 이상이면 lists를 100~2000 범위에서 실험
- probes는 lists의 1~10% 범위에서 시작
6.5 4단계: 청킹/중복 제거/메타 필터가 Recall을 죽이지 않는지 확인
- 너무 작은 chunk(예: 50~100 토큰)는 의미가 약해져 임베딩이 비슷해지고 랭킹이 불안정
- 너무 큰 chunk는 질문의 국소 근거를 못 잡음
- 메타데이터 필터(tenant_id, language, product 등)가 과하게 걸려 후보군을 지나치게 줄이면 Recall이 급락
7) 트러블슈팅: 현장에서 자주 터지는 함정들
7.1 인덱스는 있는데 왜 느리거나 품질이 낮지?
- 쿼리가 인덱스를 타는지
EXPLAIN (ANALYZE, BUFFERS)로 확인 - operator class가 거리함수와 일치하는지 확인
7.2 동일 쿼리인데 결과가 자주 바뀐다
- 근사 검색은 동점/근접 점수에서 순서가 흔들릴 수 있습니다.
- 해결:
ef_search/probes를 올려 안정화하거나, 최종 Top-N에 대해 브루트포스 재정렬(리랭킹) 적용
7.3 배치 적재 중 일부만 임베딩이 비정상
- API 실패 후 재시도 과정에서 빈 벡터/제로 벡터가 들어가면, 코사인에서 NaN/이상치가 발생할 수 있습니다.
- 비동기 파이프라인에서는 작업이 취소/정리되지 않아 누락이 생기기도 합니다. Python 비동기 워커를 돌린다면 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법처럼 작업 생명주기(취소/종료/예외)를 확실히 관리하세요.
결론: Recall@K를 회복시키는 가장 짧은 길
pgvector RAG에서 검색 품질이 급락하면, 대부분은 “모델이 나빠졌다”가 아니라 스코어링/정규화/근사 인덱스 탐색량의 불일치입니다.
- cosine vs inner product를 임베딩 모델 성격과 일치시키고
- 문서/쿼리 정규화를 한 번만, 같은 규칙으로 적용하고
- HNSW는
ef_search, IVFFlat은probes부터 올리며 - 브루트포스 기준선 대비 Recall@K로 튜닝을 수치화하세요.
오늘 할 일은 간단합니다. (1) 벡터 노름 분포를 SQL로 확인하고, (2) 거리함수/인덱스 operator class 일치 여부를 점검한 뒤, (3) ef_search 또는 probes를 단계적으로 올리며 Recall@10을 기록해보세요. 이 3가지만 해도 “갑자기 멍청해진 RAG”의 절반 이상은 복구됩니다.