Published on

pgvector+PostgreSQL로 RAG 검색 품질 2배 올리기

Authors

RAG(Retrieval-Augmented Generation)의 성능은 결국 검색 품질에서 갈립니다. 같은 LLM을 써도, 검색 결과가 더 정확하고 문맥적으로 맞으면 답변 품질은 눈에 띄게 좋아집니다. 반대로 검색이 흔들리면 프롬프트를 아무리 다듬어도 환각과 엉뚱한 인용이 늘어납니다.

이 글은 pgvector와 PostgreSQL 조합만으로 RAG 검색 품질을 “체감 2배” 수준으로 끌어올릴 때 가장 효과가 컸던 방법들을 정리합니다. 핵심은 단일 벡터 검색에만 의존하지 않고, 다음 4가지를 함께 최적화하는 것입니다.

  • 청킹(Chunking)과 메타데이터 설계
  • 하이브리드 검색(벡터 + 키워드)
  • 재랭킹(Re-ranking)과 MMR로 다양성 확보
  • 인덱스/쿼리 튜닝으로 일관된 지연시간 유지

인덱스 자체 튜닝은 별도 글로 더 깊게 다뤘으니, 필요하면 함께 보세요.

왜 “벡터 검색만”으로는 품질이 안 오를까

벡터 검색은 의미 유사도를 잘 잡지만, RAG에서는 다음 문제가 자주 발생합니다.

  1. 정확한 키워드 제약이 필요할 때 약하다
    • 에러 코드, 함수명, 설정 키 같은 토큰은 의미 임베딩에서 뭉개질 수 있습니다.
  2. 문서 구조를 무시하면 근거가 흔들린다
    • 제목, 섹션, 표, 코드 블록이 섞인 문서를 무작정 일정 길이로 자르면 “답에 필요한 단락”이 쪼개집니다.
  3. Top K만으로는 다양성이 부족하다
    • 비슷한 단락만 잔뜩 뽑혀서, 정답에 필요한 관점(정의, 예외, 제한사항, 버전 차이)이 빠집니다.

그래서 RAG 품질을 올릴 때는 “벡터 인덱스 하나 잘 만들기”보다, 검색 파이프라인을 단계적으로 설계하는 게 더 효과적입니다.

데이터 모델: 문서, 청크, 메타데이터를 먼저 고정하기

RAG에서 PostgreSQL 스키마는 단순해 보이지만, 품질을 좌우하는 포인트가 있습니다.

  • 문서 단위(documents)와 청크 단위(chunks)를 분리
  • 청크에 다음 메타를 꼭 포함
    • doc_id, section, title, url, created_at
    • token_count 또는 char_count
    • 가능하면 heading_path 같은 계층 정보

아래는 가장 흔히 쓰는 형태입니다.

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

CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  source TEXT NOT NULL,
  title TEXT NOT NULL,
  url TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- embedding_dim은 사용 모델 차원에 맞추세요. 예: 1536, 3072 등
CREATE TABLE chunks (
  id BIGSERIAL PRIMARY KEY,
  doc_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  section TEXT,
  heading_path TEXT,
  content TEXT NOT NULL,
  content_tsv tsvector GENERATED ALWAYS AS (
    to_tsvector('simple', content)
  ) STORED,
  content_trgm TEXT GENERATED ALWAYS AS (content) STORED,
  embedding vector(1536) NOT NULL,
  token_count INT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- 키워드 검색용 인덱스
CREATE INDEX chunks_tsv_idx ON chunks USING GIN (content_tsv);
CREATE INDEX chunks_trgm_idx ON chunks USING GIN (content_trgm gin_trgm_ops);

to_tsvector는 언어에 따라 english, simple 등을 선택할 수 있습니다. 한국어는 형태소 처리가 필요해 PostgreSQL 기본 FTS만으로는 한계가 있지만, “에러 코드/설정 키/영문 토큰”이 섞인 기술 문서에서는 simple도 의외로 실용적입니다. 한국어 토큰이 중요한 도메인이라면 별도 토크나이저 연동을 검토하세요.

청킹 전략: 검색 품질을 가장 크게 올리는 레버

검색 품질이 안 나올 때, 임베딩 모델을 바꾸기 전에 먼저 청킹을 점검해야 합니다.

1) 고정 길이 청킹을 버리고 “구조 기반 + 슬라이딩 윈도우”로

  • 문서의 헤딩(H2, H3) 기준으로 먼저 나누고
  • 섹션이 길면 슬라이딩 윈도우로 추가 분할
  • 오버랩을 10% 내외로 주면 문맥 손실이 줄어듭니다

권장 가이드(경험칙)

  • 일반 문서: 300~600 토큰
  • 코드/설정 문서: 150~350 토큰
  • 오버랩: 30~80 토큰

2) 청크에 “제목 프리픽스”를 넣어 임베딩 품질을 올리기

임베딩 입력에 청크 본문만 넣으면, 섹션 맥락이 사라집니다. 아래처럼 제목 정보를 프리픽스로 붙이면 유사도 검색이 훨씬 안정적입니다.

[Title] PostgreSQL pgvector 튜닝
[Section] HNSW 파라미터
[Content]
...본문...

이건 비용이 거의 안 드는데 효과는 큽니다. 특히 “짧은 청크”를 쓸수록 제목 프리픽스의 이득이 커집니다.

하이브리드 검색: 벡터 + 키워드를 섞어야 정답률이 오른다

RAG에서 자주 등장하는 질문은 의미 검색만으로는 부족합니다.

  • ImagePullBackOff 403 해결” 같은 키워드 중심 질의
  • 특정 설정 키, 옵션, 버전 문자열
  • 에러 메시지 일부

벡터는 의미적 근접도를 주고, 키워드는 정확한 토큰 매칭을 줍니다. 둘을 섞으면 “정확도”와 “재현율”이 동시에 좋아집니다.

1) 후보군을 넓히고, 점수로 합치기

전략은 간단합니다.

  1. 벡터로 상위 K1개 후보 추출
  2. FTS 또는 trigram으로 상위 K2개 후보 추출
  3. 합집합 후보를 만든 뒤, 스코어를 정규화해서 최종 정렬

PostgreSQL에서 한 번에 처리하는 예시는 아래처럼 구성할 수 있습니다.

WITH
q AS (
  SELECT
    $1::vector AS qvec,
    plainto_tsquery('simple', $2) AS qts
),
vec AS (
  SELECT
    id,
    doc_id,
    1.0 / (1.0 + (embedding <=> (SELECT qvec FROM q))) AS vec_score
  FROM chunks
  ORDER BY embedding <=> (SELECT qvec FROM q)
  LIMIT 80
),
fts AS (
  SELECT
    id,
    doc_id,
    ts_rank_cd(content_tsv, (SELECT qts FROM q)) AS fts_score
  FROM chunks
  WHERE content_tsv @@ (SELECT qts FROM q)
  ORDER BY fts_score DESC
  LIMIT 80
),
merged AS (
  SELECT
    COALESCE(vec.id, fts.id) AS id,
    COALESCE(vec.doc_id, fts.doc_id) AS doc_id,
    COALESCE(vec.vec_score, 0) AS vec_score,
    COALESCE(fts.fts_score, 0) AS fts_score
  FROM vec
  FULL OUTER JOIN fts USING (id)
)
SELECT
  c.id,
  c.doc_id,
  c.section,
  c.heading_path,
  c.content,
  (0.75 * vec_score + 0.25 * fts_score) AS score
FROM merged m
JOIN chunks c ON c.id = m.id
ORDER BY score DESC
LIMIT 20;

포인트

  • 거리(embedding <=> qvec)를 그대로 쓰지 말고, 1 / (1 + distance) 같은 형태로 점수화하면 합치기 쉽습니다.
  • 가중치 0.750.25는 시작점일 뿐입니다. 도메인에 따라 키워드 비중을 더 키우면 품질이 좋아지는 경우가 많습니다.

2) trigram은 “질의 오타”와 “부분 문자열”에 강하다

FTS가 잘 못 잡는 케이스(오타, 띄어쓰기, 일부 문자열)에서 trigram이 유용합니다.

SELECT id, content
FROM chunks
WHERE content % $1
ORDER BY similarity(content, $1) DESC
LIMIT 20;

% 연산자는 pg_trgm의 유사도 매칭입니다. 에러 메시지 일부만 기억나는 상황에서 특히 강합니다.

재랭킹: Top K를 그대로 쓰지 말고 “2단계”로 정렬하라

하이브리드 검색으로 후보군을 잘 모아도, 최종 Top 5가 애매하면 답변 품질은 바로 무너집니다. 이때 가장 효과적인 방법이 재랭킹입니다.

1) Cross-encoder 재랭킹(가능하면 가장 강력)

구성

  • 1단계: pgvector로 Top 50~200 후보
  • 2단계: 재랭커 모델로 질의-문서 쌍을 점수화

재랭킹은 비용이 들지만, 후보군이 100개 내외면 충분히 실시간으로 가능합니다. 특히 “질문이 짧고 모호한 경우”에 개선 폭이 큽니다.

재랭커를 붙이기 어렵다면, 아래의 MMR만으로도 체감 개선이 납니다.

2) MMR(Maximal Marginal Relevance)로 다양성 확보

문제

  • 벡터 Top K는 비슷한 단락만 연속으로 뽑히는 경향

해결

  • 유사도는 유지하되, 이미 선택된 청크들과의 중복을 페널티로 주어 다양성을 확보

애플리케이션 레벨에서 구현하는 예시(Python)

import numpy as np

def cosine(a, b):
    a = a / (np.linalg.norm(a) + 1e-12)
    b = b / (np.linalg.norm(b) + 1e-12)
    return float(np.dot(a, b))

def mmr(query_vec, cand_vecs, cand_ids, top_n=8, lam=0.7):
    selected = []
    selected_vecs = []

    remaining = list(range(len(cand_ids)))

    for _ in range(min(top_n, len(remaining))):
        best_i = None
        best_score = -1e9

        for i in remaining:
            rel = cosine(query_vec, cand_vecs[i])
            div = 0.0
            if selected_vecs:
                div = max(cosine(cand_vecs[i], sv) for sv in selected_vecs)
            score = lam * rel - (1 - lam) * div

            if score > best_score:
                best_score = score
                best_i = i

        selected.append(cand_ids[best_i])
        selected_vecs.append(cand_vecs[best_i])
        remaining.remove(best_i)

    return selected

lam을 낮추면 다양성이 늘고, 높이면 관련성이 늘어납니다. RAG는 보통 0.6~0.8 사이가 무난합니다.

검색 품질을 더 올리는 실전 팁 7가지

1) 질의 확장(Query rewrite)을 LLM으로 한 번만 수행

사용자 질문이 짧으면 검색이 흔들립니다. LLM에게 “검색용 질의”를 따로 만들게 하면 효과가 큽니다.

  • 원 질문을 유지하면서
  • 동의어, 약어 풀네임, 관련 키워드를 추가
  • 단, 과도한 확장은 노이즈를 부릅니다

예: “OOMKilled 원인” 같은 질문은 “Kubernetes OOMKilled memory limit eviction” 같은 형태로 확장하면 검색이 안정적입니다. 운영 트러블슈팅 문서가 많다면 다음 글도 함께 참고가 됩니다.

2) 메타데이터 필터링으로 도메인을 좁히기

RAG 품질이 떨어지는 흔한 이유는 “다른 제품/버전/팀 문서”가 섞이기 때문입니다.

  • source 필터
  • created_at 최신 우선
  • doc_id 또는 태그 기반 필터
SELECT id, content
FROM chunks
WHERE doc_id = $1
ORDER BY embedding <=> $2::vector
LIMIT 20;

3) 문서 중복 제거(near-duplicate)로 노이즈 줄이기

사내 위키나 릴리즈 노트는 비슷한 문단이 반복됩니다. 중복 청크가 많으면 Top K가 중복으로 채워져 품질이 떨어집니다.

  • ingest 시점에 content 해시로 exact dedup
  • trigram 유사도나 임베딩 유사도로 near-dedup

4) 청크 길이를 “질문 유형”에 따라 다르게

  • 정의/개념 질문: 조금 긴 청크가 유리
  • 에러 해결/레시피: 짧은 청크가 유리

하나로 통일하기보다, 문서 타입별로 청킹 정책을 다르게 두면 품질이 잘 오릅니다.

5) Top K를 무작정 키우지 말고, 후보군과 컨텍스트를 분리

  • 후보군: 100~200까지 늘려도 됨(재랭킹 전)
  • LLM에 넣는 컨텍스트: 6~12개 정도로 제한

후보군을 늘리는 건 검색 단계의 문제이고, 컨텍스트를 늘리는 건 생성 단계의 문제입니다. 둘을 분리하지 않으면 컨텍스트 윈도우만 낭비하고 답이 흐려집니다.

6) “근거 인용”을 전제로 청크에 위치 정보를 저장

답변에 출처를 붙이려면, url뿐 아니라 문서 내 위치가 필요합니다.

  • heading_path
  • section
  • 원문 오프셋(가능하면)

이걸 해두면 “정확한 인용”이 가능해지고, 사용자 신뢰가 올라가면서 RAG 품질이 체감상 더 좋아집니다.

7) 지연시간이 튀면 품질도 같이 떨어진다

검색이 느려지면 다음 같은 타협이 들어갑니다.

  • Top K를 줄임
  • 재랭킹을 끔
  • 하이브리드 단계를 생략

결국 품질이 하락합니다. 그래서 인덱스 튜닝과 커넥션 풀링, 쿼리 플랜 확인은 “성능”이 아니라 “품질 유지 장치”입니다. 인덱스 쪽은 아래 글에서 HNSW, IVFFlat 파라미터와 운영 팁을 자세히 다뤘습니다.

Node.js에서의 최소 구현 예시(검색 쿼리 실행)

아래는 pg로 하이브리드 검색 쿼리를 실행하는 간단 예시입니다. 벡터는 보통 애플리케이션에서 임베딩 API로 만든 뒤, PostgreSQL에 vector 타입으로 전달합니다.

주의: vector 파라미터 바인딩은 라이브러리/드라이버마다 표현이 다릅니다. 여기서는 간단히 문자열로 전달하는 형태를 예시로 듭니다.

import pg from 'pg';

const { Pool } = pg;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function hybridSearch(qvec: number[], queryText: string) {
  const client = await pool.connect();
  try {
    const vecLiteral = `[${qvec.join(',')}]`; // 예: "[0.1,0.2,...]"

    const sql = `
WITH
q AS (
  SELECT
    $1::vector AS qvec,
    plainto_tsquery('simple', $2) AS qts
),
vec AS (
  SELECT
    id,
    doc_id,
    1.0 / (1.0 + (embedding <=> (SELECT qvec FROM q))) AS vec_score
  FROM chunks
  ORDER BY embedding <=> (SELECT qvec FROM q)
  LIMIT 80
),
fts AS (
  SELECT
    id,
    doc_id,
    ts_rank_cd(content_tsv, (SELECT qts FROM q)) AS fts_score
  FROM chunks
  WHERE content_tsv @@ (SELECT qts FROM q)
  ORDER BY fts_score DESC
  LIMIT 80
),
merged AS (
  SELECT
    COALESCE(vec.id, fts.id) AS id,
    COALESCE(vec.doc_id, fts.doc_id) AS doc_id,
    COALESCE(vec.vec_score, 0) AS vec_score,
    COALESCE(fts.fts_score, 0) AS fts_score
  FROM vec
  FULL OUTER JOIN fts USING (id)
)
SELECT
  c.id,
  c.doc_id,
  c.section,
  c.heading_path,
  c.content,
  (0.75 * vec_score + 0.25 * fts_score) AS score
FROM merged m
JOIN chunks c ON c.id = m.id
ORDER BY score DESC
LIMIT 12;
`;

    const { rows } = await client.query(sql, [vecLiteral, queryText]);
    return rows;
  } finally {
    client.release();
  }
}

운영에서 “품질 2배”를 만드는 체크리스트

아래 항목을 순서대로 적용하면, 대부분의 RAG에서 검색 품질이 유의미하게 상승합니다.

  1. 청킹을 구조 기반으로 바꾸고, 제목 프리픽스를 임베딩에 포함
  2. 벡터 단독 검색을 하이브리드(FTS 또는 trigram)로 전환
  3. 후보군을 넓힌 뒤 재랭킹 또는 MMR로 최종 Top K를 정제
  4. 메타데이터 필터(소스, 버전, 날짜)로 도메인 혼입을 차단
  5. 인덱스/쿼리 튜닝으로 지연시간 분산을 줄여 기능을 “항상 켜둘 수 있게” 만들기

RAG는 한 방에 끝나는 최적화가 아니라, 검색 파이프라인을 제품처럼 다듬는 작업입니다. pgvector + PostgreSQL은 그 과정에서 “단일 데이터베이스 안에서” 하이브리드 검색과 운영 튜닝을 함께 가져갈 수 있다는 점이 큰 강점입니다.

다음 단계로는, 검색 로그를 쌓아 “질문-정답-근거” 평가셋을 만들고, 가중치(vec_scorefts_score)와 청킹 파라미터를 자동으로 튜닝하는 방향까지 확장하면 안정적으로 품질을 끌어올릴 수 있습니다.