Aller au contenu principal
William Balance
← 블로그 목록

Claude와 pgvector로 첫 RAG 파이프라인 구축하기

게시일 2026년 4월 8일 · 8분 읽기
  • RAG
  • Claude
  • pgvector
  • PostgreSQL
  • TypeScript

대부분의 RAG 글은 새 벡터 DB, 새 프레임워크, 새 오케스트레이터를 권합니다. v1을 출시하는 데는 그 어떤 것도 필요 없습니다. pgvector를 얹은 Postgres에 Claude API 하나면 라이브로 갈 수 있습니다. 실제로 클라이언트 프로젝트의 10건 중 9건에서 제가 배포하는 구성입니다.

기존 Postgres 위에 도메인 Q&A 기능이 필요할 때 제가 그대로 출하하는 파이프라인을 정리합니다.

왜 전용 벡터 DB가 아니라 pgvector인가

세 가지 이유입니다.

  • 이미 Postgres를 운영 중입니다. 추가로 운영하고, 모니터링하고, 백업할 서비스가 없습니다.
  • 트랜잭션 보장. 벡터가 비즈니스 데이터 옆에 있고, 같은 DB, 같은 권한, 같은 백업 전략을 씁니다.
  • 사람들이 말하는 것보다 훨씬 멀리 갑니다. HNSW 인덱스를 단 수백만 벡터가 중간 크기 Postgres에서 충분히 잘 돕니다.

5천만 벡터를 넘거나 리전 간 50ms 미만의 검색이 필요할 때만 매니지드 벡터 DB로 갑니다. 그 아래에서는 단순함에서 pgvector가 매번 이깁니다.

네 단계 파이프라인

  1. 문서를 수집(ingest) 하고 청크로 분할합니다.
  2. 각 청크를 임베딩(embed) 하고 벡터를 저장합니다.
  3. 사용자 쿼리에 대해 상위 k개 청크를 검색(retrieve) 합니다.
  4. 검색된 청크에 근거(grounding)해 Claude로 답을 생성(generate) 합니다.

하나씩 살펴봅시다.

1. 스키마와 인덱스

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL,
  source TEXT NOT NULL,
  title TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE chunks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
  chunk_index INT NOT NULL,
  content TEXT NOT NULL,
  embedding vector(1536),
  token_count INT
);

CREATE INDEX chunks_embedding_hnsw
  ON chunks USING hnsw (embedding vector_cosine_ops);

CREATE INDEX chunks_document_id ON chunks(document_id);

OpenAI text-embedding-3-small과 짝을 맞춘다면 1536차원을 씁니다. Claude는 아직 자체 임베딩이 없어서, 생성은 Claude로 하고 임베딩은 OpenAI나 Voyage로 짝지어 씁니다. 이념이 아니라 그냥 작동하는 조합입니다.

2. 청킹과 임베딩

청킹은 대부분의 RAG 파이프라인이 조용히 죽는 지점입니다. 임의의 문자 수로 자르지 마세요. 오버랩을 둔 문단 단위로 자르거나 시맨틱 스플리터를 쓰세요. 검색 코드를 쓰기 전에 실제 문서로 분할 결과를 먼저 검증하세요.

import OpenAI from "openai";
import { db } from "./db";

const openai = new OpenAI();

export async function ingestDocument(docId: string, text: string) {
  const chunks = splitByParagraph(text, { maxTokens: 500, overlap: 50 });

  const embeddings = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: chunks.map((c) => c.content),
  });

  await db.transaction(async (tx) => {
    for (let i = 0; i < chunks.length; i++) {
      await tx.insert(chunksTable).values({
        document_id: docId,
        chunk_index: i,
        content: chunks[i].content,
        embedding: embeddings.data[i].embedding,
        token_count: chunks[i].tokens,
      });
    }
  });
}

임베딩 호출은 배치로 묶으세요. 청크 100개를 한 번에 보내는 게 100번 순차 호출보다 압도적으로 싸고 빠릅니다. 이걸 빼먹으면 인제스트 잡이 기어다닙니다.

3. 한 끗 다른 검색

순진한 검색은 코사인 유사도로 최근접 이웃을 돌려줍니다. 그게 베이스라인입니다. 실전에서는 두 가지 업그레이드가 중요합니다.

  • 벡터 검색 전에 메타데이터 필터링. 사용자가 특정 프로젝트로 제한된다면 먼저 document.project_id로 필터링하세요. 이걸 빠뜨리면 한 테넌트의 데이터가 다른 테넌트로 새는 사고가 납니다.
  • 검색 후 리랭킹(reranking). 벡터 검색의 상위 10개는 노이즈가 많습니다. 리랭커(Cohere, Voyage, 또는 크로스 인코더)가 실제 쿼리 기준으로 재정렬합니다. 저렴하고, 품질 향상은 큽니다.
export async function retrieve(query: string, projectId: string, k = 5) {
  const embeddingResponse = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: [query],
  });
  const queryVec = embeddingResponse.data[0].embedding;

  // 메타데이터 필터를 적용한 벡터 검색
  const candidates = await db.query(
    `
    SELECT c.id, c.content, d.title
    FROM chunks c
    JOIN documents d ON d.id = c.document_id
    WHERE d.project_id = $1
    ORDER BY c.embedding <=> $2::vector
    LIMIT 20
    `,
    [projectId, queryVec],
  );

  // 정밀도 향상을 위한 리랭킹
  return rerank(query, candidates, k);
}

4. Claude로 생성

Claude는 긴 컨텍스트 윈도우와 grounding 지시를 잘 따른다는 점에서 여기서 빛을 발합니다. 검색된 청크를 넘기고, 명확한 지시를 주고, 인용을 요구하세요.

import Anthropic from "@anthropic-ai/sdk";

const claude = new Anthropic();

export async function answer(query: string, chunks: Chunk[]) {
  const context = chunks
    .map((c, i) => `[${i + 1}] ${c.title}\n${c.content}`)
    .join("\n\n---\n\n");

  const response = await claude.messages.create({
    model: "claude-sonnet-4-5",
    max_tokens: 1024,
    system: `You are a precise assistant. Answer the user's question using ONLY the provided context. Cite sources with [1], [2] markers. If the context does not contain the answer, say so.`,
    messages: [
      {
        role: "user",
        content: `Context:\n${context}\n\nQuestion: ${query}`,
      },
    ],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}

제가 절대 빼지 않는 프롬프트 규칙 두 가지입니다.

  • 명시적 grounding: “use ONLY the provided context”.
  • 명시적 폴백: “if the context does not contain the answer, say so”. 이걸 빼면 Claude가 자신만만한 헛소리로 빈칸을 채웁니다.

프로덕션에서 측정할 것

라이브로 가면 다음 네 가지를 추적하세요.

  • 검색 적중률(retrieval hit rate). 실제 쿼리 샘플에서 정답이 상위 k개 안에 있나요? 없다면 문제는 모델이 아니라 청킹이나 임베딩입니다.
  • 근거성(groundedness). 답이 정말로 검색된 청크에 기반하고 있나요? LLM-as-judge에 매주 사람이 직접 표본 검수.
  • 레이턴시 분해. 임베딩 호출, 벡터 검색, 생성. 가장 느린 곳을 최적화하세요.
  • 쿼리당 비용. 임베딩 토큰 + 생성 토큰. 수다스러운 시스템 프롬프트는 하룻밤 사이 청구서를 두 배로 만듭니다.

다음 단계

v1이 돌기 시작하면 흔한 업그레이드는 하이브리드 검색(BM25 + 벡터), 쿼리 리라이팅(모호한 사용자 입력을 더 좋은 검색 쿼리로 변환), 그리고 에이전틱 검색(Claude가 무엇을 찾을지 직접 결정)입니다. 셋 다 가치가 있지만 — 지루하고 grounding이 잘 된 v1이 프로덕션에 떠 있고 실사용 데이터가 쌓인 다음에 손대세요. 먼저 출하하고, 증거 위에서 최적화합니다.

RAG 아키텍처에 대한 두 번째 의견이 필요하다면 30분 스코핑 콜을 진행합니다. 실제 쿼리 로그를 들고 오세요.

진행 중인 프로젝트가 있나요?

AI든 풀스택이든, 무료 30분 동안 함께 이야기해 봅니다.