RAG 실전 정리 (Spring AI + pgvector 기준)

정세현·2026년 7월 21일

NHN AX Internship Notes

목록 보기
2/16

목표: "RAG가 실제로 어떤 코드로 굴러가는가"를 인덱싱 → 검색 → 증강 → 생성 전 구간에 걸쳐 실행 가능한 코드 레벨로 이해한다.


목차

  1. RAG는 왜 필요한가
  2. 전체 아키텍처
  3. 환경 세팅
  4. Phase 1 — 인덱싱 파이프라인
  5. Phase 2 — 검색 (Retrieval)
  6. Phase 3 — 증강 (Augmentation)
  7. Phase 4 — 생성 (Generation)
  8. Advanced RAG 기법
  9. Spring AI 1.0의 RAG Advisor 방식
  10. SSE 스트리밍 응답
  11. 평가와 모니터링
  12. 실무 함정 체크리스트

1. RAG는 왜 필요한가

LLM 단독으로 사내 문서 질의응답을 시키면 세 가지 문제가 생긴다.

문제설명RAG의 해법
지식 컷오프학습 시점 이후 정보를 모름최신 문서를 런타임에 주입
사내 데이터 부재비공개 문서는 애초에 학습에 없음자체 벡터 DB에서 검색
환각(Hallucination)모르는 걸 그럴듯하게 지어냄근거 문서 밖 답변을 프롬프트로 차단 + 출처 표기

핵심 아이디어는 단순하다.

"LLM에게 시험 문제를 주기 전에, 오픈북 교재의 관련 페이지를 찢어서 같이 건네준다."

파인튜닝과의 차이:

구분파인튜닝RAG
지식 갱신재학습 필요 (비쌈)문서만 다시 인덱싱 (쌈)
출처 추적불가능가능 (citation)
적합한 용도말투·형식·태스크 학습사실 기반 지식 주입

실무에서는 둘 다 쓰지만, "사실을 알려주고 싶으면 RAG, 스타일을 가르치고 싶으면 파인튜닝" 이 기본 판단 기준이다.


2. 전체 아키텍처

┌─────────────────── 오프라인: 인덱싱 파이프라인 ───────────────────┐
│                                                                  │
│  원본 문서        문서 로딩       청크 분할       임베딩          │
│  (PDF/MD/DB) ──▶ DocumentReader ─▶ TextSplitter ─▶ EmbeddingModel │
│                                                        │         │
│                                                        ▼         │
│                                              ┌──────────────────┐│
│                                              │ VectorStore      ││
│                                              │ (pgvector)       ││
│                                              │ id/content/      ││
│                                              │ metadata/embedding││
│                                              └──────────────────┘│
└──────────────────────────────────────────────────────┬───────────┘
                                                       │
┌─────────────────── 온라인: 질의 처리 ─────────────────┼───────────┐
│                                                       │           │
│  사용자 질문                                          │           │
│      │                                                │           │
│      ├─▶ (선택) Query Transformation                  │           │
│      │      · 질문 재작성 / HyDE / Multi-Query        │           │
│      │                                                ▼           │
│      ├─▶ EmbeddingModel ──▶ 벡터 ──▶ similaritySearch()          │
│      │                                    │                       │
│      │                                    ▼                       │
│      │                            Top-K 청크 반환                 │
│      │                                    │                       │
│      │                          (선택) Reranking                  │
│      │                                    │                       │
│      ▼                                    ▼                       │
│  ┌────────────────────────────────────────────────┐              │
│  │  PromptTemplate: 시스템 지침 + 컨텍스트 + 질문 │              │
│  └────────────────────────┬───────────────────────┘              │
│                           ▼                                       │
│                    ChatClient.call()  ──▶  LLM  ──▶  답변 + 출처 │
└───────────────────────────────────────────────────────────────────┘

용어 정리:

  • 오프라인 단계: 문서가 추가/변경될 때만 돈다. 배치 잡이나 이벤트 기반.
  • 온라인 단계: 사용자 요청마다 돈다. 레이턴시가 중요.

3. 환경 세팅

3.1 의존성 (Gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Spring AI BOM으로 버전 관리
    implementation platform("org.springframework.ai:spring-ai-bom:1.0.0")

    // LLM 프로바이더 (택 1 또는 병용)
    implementation 'org.springframework.ai:spring-ai-starter-model-anthropic'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai'

    // 벡터 스토어
    implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'

    // 문서 리더
    implementation 'org.springframework.ai:spring-ai-tika-document-reader'
}

3.2 application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/ragdb
    username: rag
    password: ${DB_PASSWORD}

  ai:
    # 임베딩은 OpenAI, 생성은 Anthropic으로 분리하는 구성
    openai:
      api-key: ${OPENAI_API_KEY}
      embedding:
        options:
          model: text-embedding-3-small   # 1536차원, 저렴
    anthropic:
      api-key: ${ANTHROPIC_API_KEY}
      chat:
        options:
          model: claude-sonnet-4-6
          temperature: 0.1                # RAG는 낮게 (창작 아님)
          max-tokens: 2048

    vectorstore:
      pgvector:
        index-type: HNSW                  # 근사 최근접 탐색 인덱스
        distance-type: COSINE_DISTANCE
        dimensions: 1536
        initialize-schema: true           # 개발용, 운영은 Flyway 권장

주의: dimensions는 임베딩 모델 출력 차원과 반드시 일치해야 한다. 모델을 바꾸면 기존 벡터는 전부 무효가 되므로 재인덱싱이 필수다.

3.3 pgvector 스키마 (Flyway 마이그레이션)

-- V1__init_vector_store.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE IF NOT EXISTS vector_store (
    id        UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    content   TEXT       NOT NULL,
    metadata  JSONB,
    embedding VECTOR(1536)
);

-- HNSW: 정확도 높고 검색 빠름, 인덱스 빌드는 느림
CREATE INDEX IF NOT EXISTS vector_store_embedding_idx
    ON vector_store
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- 메타데이터 필터링 성능용
CREATE INDEX IF NOT EXISTS vector_store_metadata_idx
    ON vector_store USING gin (metadata);

HNSW vs IVFFlat

HNSWIVFFlat
검색 속도빠름보통
재현율(recall)높음중간
인덱스 빌드느림, 메모리 많이 씀빠름
데이터 추가실시간 반영 양호주기적 REINDEX 필요

문서 수가 100만 미만이고 실시간 인입이 있으면 HNSW가 무난하다.

3.4 Docker Compose

services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_DB: ragdb
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: ragpw
    ports: ["5432:5432"]
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U rag -d ragdb"]
      interval: 5s
      retries: 10

volumes:
  pgdata:

4. Phase 1 — 인덱싱 파이프라인

4.1 문서 로딩

Spring AI는 DocumentReader 인터페이스로 다양한 포맷을 흡수한다.

@Component
@RequiredArgsConstructor
public class DocumentLoader {

    /** PDF, DOCX, HTML, PPTX 등 대부분의 포맷을 Tika가 처리 */
    public List<Document> loadFromFile(Resource resource) {
        TikaDocumentReader reader = new TikaDocumentReader(resource);
        return reader.get();
    }

    /** 마크다운은 헤더 구조를 살려서 읽는 전용 리더가 있다 */
    public List<Document> loadMarkdown(Resource resource) {
        MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
            .withHorizontalRuleCreateDocument(true)  // --- 기준으로 문서 분리
            .withIncludeCodeBlock(true)
            .withIncludeBlockquote(true)
            .withAdditionalMetadata("source_type", "markdown")
            .build();

        return new MarkdownDocumentReader(resource, config).get();
    }

    /** DB 레코드처럼 이미 구조화된 데이터는 직접 Document를 만든다 */
    public List<Document> loadFromTickets(List<Ticket> tickets) {
        return tickets.stream()
            .map(t -> new Document(
                // 검색 품질을 위해 제목+본문을 같이 넣는다
                """
                제목: %s
                작성자: %s
                본문:
                %s
                """.formatted(t.getTitle(), t.getAuthor(), t.getBody()),
                Map.of(
                    "ticket_id", t.getId(),
                    "project",   t.getProjectKey(),
                    "status",    t.getStatus().name(),
                    "created_at", t.getCreatedAt().toString(),
                    "source",    "dooray-ticket"
                )
            ))
            .toList();
    }
}

메타데이터 설계가 곧 검색 품질이다. 나중에 "이 프로젝트 문서만", "최근 6개월만" 같은 필터를 걸려면 지금 넣어둬야 한다. 나중에 추가하려면 재인덱싱 외엔 방법이 없다.

4.2 청크 분할 (Chunking)

가장 과소평가되는 단계다. RAG 품질의 절반이 여기서 결정된다.

@Component
public class ChunkingStrategy {

    /**
     * 토큰 기반 분할 — 가장 무난한 기본값
     *
     * defaultChunkSize : 청크당 목표 토큰 수
     * minChunkSizeChars: 문장 경계를 찾을 최소 문자 수
     * minChunkLengthToEmbed: 이보다 짧으면 버림 (노이즈 제거)
     * maxNumChunks     : 문서당 최대 청크 수
     * keepSeparator    : 개행 유지 여부
     */
    public List<Document> splitByToken(List<Document> docs) {
        TokenTextSplitter splitter = new TokenTextSplitter(
            800,    // defaultChunkSize
            350,    // minChunkSizeChars
            10,     // minChunkLengthToEmbed
            10_000, // maxNumChunks
            true    // keepSeparator
        );
        return splitter.apply(docs);
    }
}

청크 크기 선택 가이드

문서 성격권장 청크오버랩이유
FAQ / Q&A200~400 토큰0~10%질문 하나가 곧 하나의 단위
기술 문서 / 매뉴얼500~1000 토큰10~20%절 단위 문맥 필요
법률 / 계약서1000~1500 토큰20%조항 간 참조 관계가 김
코드베이스함수/클래스 단위0%구문 경계가 곧 의미 경계
채팅 로그대화 스레드 단위0%시간적 응집성

오버랩이 필요한 이유

[오버랩 없음]
청크1: "...결제 실패 시 재시도 로직은 최대 3회까지 수행한다."
청크2: "이때 지수 백오프를 적용하며 초기 대기는 1초다..."
         ↑ "이때"가 뭘 가리키는지 청크2만 봐서는 모름

[오버랩 15%]
청크1: "...재시도 로직은 최대 3회까지 수행한다."
청크2: "재시도 로직은 최대 3회까지 수행한다. 이때 지수 백오프를..."
         ↑ 문맥 복원됨

구조 인식 분할 (권장)

마크다운 헤더처럼 문서 구조가 있으면 그걸 존중해야 한다.

@Component
public class HeaderAwareSplitter {

    private static final Pattern HEADER = Pattern.compile("^(#{1,3})\\s+(.+)$", Pattern.MULTILINE);

    public List<Document> split(Document doc) {
        String content = doc.getText();
        List<Document> result = new ArrayList<>();

        Matcher m = HEADER.matcher(content);
        List<int[]> boundaries = new ArrayList<>();
        List<String> titles = new ArrayList<>();

        while (m.find()) {
            boundaries.add(new int[]{ m.start() });
            titles.add(m.group(2));
        }

        for (int i = 0; i < boundaries.size(); i++) {
            int start = boundaries.get(i)[0];
            int end = (i + 1 < boundaries.size()) ? boundaries.get(i + 1)[0] : content.length();

            String section = content.substring(start, end).trim();
            if (section.length() < 50) continue;   // 헤더만 있는 빈 섹션 스킵

            Map<String, Object> meta = new HashMap<>(doc.getMetadata());
            meta.put("section_title", titles.get(i));
            meta.put("chunk_index", i);

            // 검색 정확도를 위해 섹션 제목을 본문 앞에 다시 붙인다
            String enriched = "[" + titles.get(i) + "]\n" + section;
            result.add(new Document(enriched, meta));
        }
        return result;
    }
}

왜 제목을 본문에 중복해서 넣나? 임베딩은 청크 텍스트만 보고 벡터를 만든다. "3.2절 환불 정책"이라는 맥락이 본문에 없으면, 본문에 "환불"이라는 단어가 안 나올 경우 검색이 안 된다. 제목을 심어두면 그 신호가 벡터에 반영된다.

4.3 임베딩과 저장

@Service
@RequiredArgsConstructor
@Slf4j
public class IndexingService {

    private final VectorStore vectorStore;
    private final DocumentLoader loader;
    private final ChunkingStrategy chunker;

    private static final int BATCH_SIZE = 100;

    @Transactional
    public IndexResult index(Resource resource, Map<String, Object> baseMetadata) {
        // 1) 로딩
        List<Document> docs = loader.loadFromFile(resource);
        docs.forEach(d -> d.getMetadata().putAll(baseMetadata));

        // 2) 청킹
        List<Document> chunks = chunker.splitByToken(docs);
        log.info("문서 {}개 → 청크 {}개", docs.size(), chunks.size());

        // 3) 배치 단위로 임베딩 + 저장
        //    한 번에 다 넣으면 임베딩 API rate limit에 걸린다
        int total = 0;
        for (int i = 0; i < chunks.size(); i += BATCH_SIZE) {
            List<Document> batch = chunks.subList(i, Math.min(i + BATCH_SIZE, chunks.size()));
            vectorStore.add(batch);   // 내부에서 embeddingModel.embed() 호출
            total += batch.size();
            log.debug("진행률 {}/{}", total, chunks.size());
        }

        return new IndexResult(docs.size(), chunks.size());
    }
}

vectorStore.add() 내부에서 벌어지는 일:

for each document:
    1. embeddingModel.embed(document.getText())
         → HTTP POST /v1/embeddings
         → float[1536] 반환
    2. document.setEmbedding(vector)
    3. INSERT INTO vector_store (id, content, metadata, embedding)
       VALUES (?, ?, ?::jsonb, ?::vector)

4.4 증분 인덱싱 (중복 방지)

같은 문서를 두 번 넣으면 검색 결과가 중복으로 오염된다.

@Service
@RequiredArgsConstructor
public class IncrementalIndexer {

    private final VectorStore vectorStore;
    private final JdbcTemplate jdbcTemplate;

    public void upsert(String sourceId, String rawContent, Map<String, Object> metadata) {
        String contentHash = DigestUtils.sha256Hex(rawContent);

        // 1) 기존 해시와 비교 — 안 바뀌었으면 스킵
        String existingHash = findExistingHash(sourceId);
        if (contentHash.equals(existingHash)) {
            log.info("변경 없음, 스킵: {}", sourceId);
            return;
        }

        // 2) 기존 청크 전부 삭제
        deleteBySourceId(sourceId);

        // 3) 새로 인덱싱
        metadata.put("source_id", sourceId);
        metadata.put("content_hash", contentHash);
        metadata.put("indexed_at", Instant.now().toString());

        List<Document> chunks = chunker.splitByToken(
            List.of(new Document(rawContent, metadata))
        );
        vectorStore.add(chunks);
    }

    private String findExistingHash(String sourceId) {
        List<String> results = jdbcTemplate.queryForList(
            "SELECT metadata->>'content_hash' FROM vector_store " +
            "WHERE metadata->>'source_id' = ? LIMIT 1",
            String.class, sourceId
        );
        return results.isEmpty() ? null : results.get(0);
    }

    private void deleteBySourceId(String sourceId) {
        jdbcTemplate.update(
            "DELETE FROM vector_store WHERE metadata->>'source_id' = ?",
            sourceId
        );
    }
}

5. Phase 2 — 검색 (Retrieval)

5.1 기본 유사도 검색

@Service
@RequiredArgsConstructor
public class RetrievalService {

    private final VectorStore vectorStore;

    public List<Document> retrieve(String query) {
        SearchRequest request = SearchRequest.builder()
            .query(query)
            .topK(5)
            .similarityThreshold(0.7)
            .build();

        return vectorStore.similaritySearch(request);
    }
}

실제 실행되는 SQL:

SELECT
    id,
    content,
    metadata,
    1 - (embedding <=> :queryVector) AS similarity
FROM vector_store
WHERE 1 - (embedding <=> :queryVector) >= 0.7
ORDER BY embedding <=> :queryVector
LIMIT 5;

pgvector 연산자 정리:

연산자거리용도
<->L2(유클리드)절대적 거리가 의미 있을 때
<=>코사인텍스트 임베딩 기본값
<#>내적(음수)정규화된 벡터에서 코사인과 동등, 더 빠름

5.2 메타데이터 필터링

벡터 검색 전에 후보군을 좁히면 정확도와 속도가 동시에 올라간다.

public List<Document> retrieveFiltered(String query, String projectKey, int recentDays) {
    String cutoff = LocalDate.now().minusDays(recentDays).toString();

    SearchRequest request = SearchRequest.builder()
        .query(query)
        .topK(5)
        .similarityThreshold(0.7)
        .filterExpression(
            "project == '%s' && created_at >= '%s'".formatted(projectKey, cutoff)
        )
        .build();

    return vectorStore.similaritySearch(request);
}

빌더 API로 타입 안전하게 쓰는 방법:

FilterExpressionBuilder b = new FilterExpressionBuilder();

Filter.Expression expr = b.and(
    b.eq("project", "DOORAY"),
    b.or(
        b.eq("status", "RESOLVED"),
        b.eq("status", "CLOSED")
    )
).build();

SearchRequest request = SearchRequest.builder()
    .query(query)
    .topK(5)
    .filterExpression(expr)
    .build();

지원 연산자: ==, !=, >, >=, <, <=, in, nin, &&, ||, not

5.3 하이브리드 검색 (벡터 + 키워드)

벡터 검색의 약점: 정확한 고유명사·에러코드·제품명을 놓친다.

예를 들어 "ERR_TIMEOUT_5023"을 물어보면, 임베딩은 이 문자열의 의미를 모르므로 "타임아웃 관련 문서"를 뭉뚱그려 가져온다. 반면 키워드 검색은 정확히 일치하는 걸 찾는다. 둘을 합치는 게 하이브리드 검색이다.

@Service
@RequiredArgsConstructor
public class HybridSearchService {

    private final VectorStore vectorStore;
    private final JdbcTemplate jdbcTemplate;
    private final EmbeddingModel embeddingModel;

    private static final double K = 60.0;  // RRF 상수

    public List<Document> hybridSearch(String query, int topK) {
        // 1) 의미 검색
        List<Document> semantic = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(topK * 2).build()
        );

        // 2) 키워드 검색 (PostgreSQL full-text search)
        List<Document> keyword = keywordSearch(query, topK * 2);

        // 3) Reciprocal Rank Fusion으로 병합
        return reciprocalRankFusion(List.of(semantic, keyword), topK);
    }

    private List<Document> keywordSearch(String query, int limit) {
        String sql = """
            SELECT id, content, metadata,
                   ts_rank(to_tsvector('simple', content),
                           plainto_tsquery('simple', ?)) AS rank
            FROM vector_store
            WHERE to_tsvector('simple', content) @@ plainto_tsquery('simple', ?)
            ORDER BY rank DESC
            LIMIT ?
            """;
        return jdbcTemplate.query(sql, documentRowMapper(), query, query, limit);
    }

    /**
     * RRF: 각 리스트에서의 순위 역수를 합산한다.
     * score(d) = Σ 1 / (k + rank_i(d))
     *
     * 서로 다른 스케일의 점수(코사인 유사도 vs ts_rank)를
     * 정규화 없이 합칠 수 있어서 실무에서 애용된다.
     */
    private List<Document> reciprocalRankFusion(List<List<Document>> rankedLists, int topK) {
        Map<String, Double> scores = new HashMap<>();
        Map<String, Document> byId = new HashMap<>();

        for (List<Document> list : rankedLists) {
            for (int rank = 0; rank < list.size(); rank++) {
                Document doc = list.get(rank);
                String id = doc.getId();
                scores.merge(id, 1.0 / (K + rank + 1), Double::sum);
                byId.putIfAbsent(id, doc);
            }
        }

        return scores.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(topK)
            .map(e -> byId.get(e.getKey()))
            .toList();
    }
}

한국어 full-text search를 제대로 하려면 pg_bigm 확장이나 형태소 분석기(mecab-ko) 연동이 필요하다. simple 설정은 공백 단위 토크나이징이라 한계가 있다.


6. Phase 3 — 증강 (Augmentation)

6.1 프롬프트 설계

RAG 프롬프트는 세 가지 일을 해야 한다. (1) 컨텍스트 밖 답변 금지, (2) 모를 때 모른다고 말하기, (3) 출처 명시.

@Component
public class RagPromptBuilder {

    private static final String SYSTEM_TEMPLATE = """
        당신은 사내 문서 기반 질의응답 어시스턴트입니다.

        ## 규칙
        1. 아래 <context> 안의 정보만 사용해 답변하세요.
        2. context에 없는 내용은 절대 추측하지 마세요.
           답할 수 없으면 "제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 하세요.
        3. 답변의 각 문장 끝에 근거 문서 번호를 [1], [2] 형식으로 표기하세요.
        4. context 내용이 서로 모순되면 그 사실을 명시하고 양쪽을 모두 제시하세요.
        5. 답변은 한국어로, 불필요한 서론 없이 핵심부터 작성하세요.

        <context>
        {context}
        </context>
        """;

    public Prompt build(String userQuery, List<Document> docs) {
        String context = formatContext(docs);

        SystemPromptTemplate systemTemplate = new SystemPromptTemplate(SYSTEM_TEMPLATE);
        Message systemMessage = systemTemplate.createMessage(Map.of("context", context));
        Message userMessage = new UserMessage(userQuery);

        return new Prompt(List.of(systemMessage, userMessage));
    }

    /** 문서마다 번호와 출처를 붙여야 LLM이 인용할 수 있다 */
    private String formatContext(List<Document> docs) {
        return IntStream.range(0, docs.size())
            .mapToObj(i -> {
                Document d = docs.get(i);
                return """
                    [%d] 출처: %s | 섹션: %s
                    %s
                    """.formatted(
                        i + 1,
                        d.getMetadata().getOrDefault("source", "unknown"),
                        d.getMetadata().getOrDefault("section_title", "-"),
                        d.getText()
                    );
            })
            .collect(Collectors.joining("\n---\n"));
    }
}

6.2 컨텍스트 윈도우 관리

검색된 문서를 무조건 다 넣으면 토큰 한도를 넘거나, 비용이 폭증하거나, "Lost in the Middle" 현상이 생긴다.

Lost in the Middle: LLM은 긴 컨텍스트의 처음과 끝은 잘 보지만 중간은 놓치는 경향이 있다. 가장 관련도 높은 문서를 맨 앞과 맨 뒤에 배치하는 게 유리하다.

@Component
public class ContextWindowManager {

    private static final int MAX_CONTEXT_TOKENS = 6000;

    private final TokenCountEstimator tokenEstimator = new JTokkitTokenCountEstimator();

    /** 토큰 예산 안에서 문서를 잘라낸다 */
    public List<Document> fitToBudget(List<Document> docs) {
        List<Document> selected = new ArrayList<>();
        int used = 0;

        for (Document doc : docs) {
            int tokens = tokenEstimator.estimate(doc.getText());
            if (used + tokens > MAX_CONTEXT_TOKENS) break;
            selected.add(doc);
            used += tokens;
        }
        return selected;
    }

    /**
     * Lost in the Middle 완화:
     * 1등 → 맨 앞, 2등 → 맨 뒤, 3등 → 앞에서 두 번째 ... 지그재그 배치
     */
    public List<Document> reorderForAttention(List<Document> ranked) {
        Deque<Document> result = new ArrayDeque<>();
        for (int i = 0; i < ranked.size(); i++) {
            if (i % 2 == 0) result.addFirst(ranked.get(i));
            else            result.addLast(ranked.get(i));
        }
        return new ArrayList<>(result);
    }
}

7. Phase 4 — 생성 (Generation)

7.1 전체를 엮은 서비스

@Service
@RequiredArgsConstructor
@Slf4j
public class RagChatService {

    private final ChatClient chatClient;
    private final RetrievalService retrievalService;
    private final RagPromptBuilder promptBuilder;
    private final ContextWindowManager windowManager;

    public RagResponse ask(RagRequest request) {
        long start = System.currentTimeMillis();

        // 1) 검색
        List<Document> retrieved = retrievalService.retrieveFiltered(
            request.query(), request.projectKey(), 180
        );

        if (retrieved.isEmpty()) {
            return RagResponse.noContext(
                "관련 문서를 찾지 못했습니다. 질문을 더 구체적으로 작성해 주세요."
            );
        }

        // 2) 컨텍스트 정리
        List<Document> fitted = windowManager.fitToBudget(retrieved);
        List<Document> ordered = windowManager.reorderForAttention(fitted);

        // 3) 프롬프트 조립
        Prompt prompt = promptBuilder.build(request.query(), ordered);

        // 4) 생성
        ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
        String answer = response.getResult().getOutput().getText();

        long elapsed = System.currentTimeMillis() - start;
        log.info("RAG 완료: query='{}', docs={}, tokens={}, {}ms",
            request.query(),
            ordered.size(),
            response.getMetadata().getUsage().getTotalTokens(),
            elapsed);

        // 5) 출처와 함께 반환
        return new RagResponse(
            answer,
            ordered.stream().map(this::toCitation).toList(),
            elapsed
        );
    }

    private Citation toCitation(Document doc) {
        return new Citation(
            doc.getId(),
            (String) doc.getMetadata().get("source"),
            (String) doc.getMetadata().get("section_title"),
            (Double) doc.getMetadata().get("distance")
        );
    }
}

7.2 DTO

public record RagRequest(
    String query,
    String projectKey,
    String conversationId
) {}

public record RagResponse(
    String answer,
    List<Citation> citations,
    long elapsedMs
) {
    public static RagResponse noContext(String message) {
        return new RagResponse(message, List.of(), 0L);
    }
}

public record Citation(
    String documentId,
    String source,
    String sectionTitle,
    Double distance
) {}

7.3 컨트롤러

@RestController
@RequestMapping("/api/rag")
@RequiredArgsConstructor
public class RagController {

    private final RagChatService ragChatService;

    @PostMapping("/ask")
    public ResponseEntity<RagResponse> ask(@RequestBody @Valid RagRequest request) {
        return ResponseEntity.ok(ragChatService.ask(request));
    }
}

8. Advanced RAG 기법

기본 RAG(naive RAG)로 안 풀리는 케이스들이 있다. 대표적인 개선 기법 다섯 가지.

8.1 Query Rewriting

문제: 사용자가 "그거 어떻게 해?"처럼 대화 맥락에 의존해 질문한다. 이대로 임베딩하면 검색이 안 된다.

@Service
@RequiredArgsConstructor
public class QueryRewriter {

    private final ChatClient chatClient;

    private static final String REWRITE_PROMPT = """
        아래 대화 이력을 참고해서, 마지막 질문을 독립적으로 이해 가능한
        검색 쿼리로 재작성하세요. 설명 없이 재작성된 쿼리만 출력하세요.

        대화 이력:
        {history}

        마지막 질문: {question}
        """;

    public String rewrite(String question, List<Message> history) {
        if (history.isEmpty()) return question;

        String historyText = history.stream()
            .map(m -> m.getMessageType() + ": " + m.getText())
            .collect(Collectors.joining("\n"));

        return chatClient.prompt()
            .user(u -> u.text(REWRITE_PROMPT)
                        .param("history", historyText)
                        .param("question", question))
            .call()
            .content()
            .trim();
    }
}

동작 예시:

history: "Q: Dooray 웹훅 설정 방법은? / A: 프로젝트 설정 > 서비스 연동에서..."
question: "그거 인증은 어떻게 해?"
   ↓
rewritten: "Dooray 웹훅 인증 설정 방법"

8.2 Multi-Query (질문 분해)

하나의 질문을 여러 각도로 변형해서 검색 재현율을 높인다.

@Service
@RequiredArgsConstructor
public class MultiQueryRetriever {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    private static final String EXPAND_PROMPT = """
        다음 질문을 서로 다른 관점의 검색 쿼리 3개로 변형하세요.
        각 줄에 하나씩, 번호나 설명 없이 쿼리만 출력하세요.

        질문: {question}
        """;

    public List<Document> retrieve(String question, int topK) {
        List<String> queries = new ArrayList<>();
        queries.add(question);   // 원본도 포함

        String expanded = chatClient.prompt()
            .user(u -> u.text(EXPAND_PROMPT).param("question", question))
            .call().content();

        queries.addAll(expanded.lines()
            .map(String::trim)
            .filter(s -> !s.isBlank())
            .toList());

        // 각 쿼리로 검색 후 문서 ID 기준 중복 제거
        Map<String, Document> merged = new LinkedHashMap<>();
        for (String q : queries) {
            vectorStore.similaritySearch(
                SearchRequest.builder().query(q).topK(topK).build()
            ).forEach(d -> merged.putIfAbsent(d.getId(), d));
        }
        return new ArrayList<>(merged.values());
    }
}

8.3 HyDE (Hypothetical Document Embeddings)

문제: 질문과 답변 문서는 문체가 다르다. "환불은 어떻게 하나요?"와 "환불 정책: 구매 후 7일 이내..." 는 임베딩 공간에서 생각보다 멀다.

해법: LLM에게 가상의 답변을 먼저 쓰게 하고, 그 답변으로 검색한다.

@Service
@RequiredArgsConstructor
public class HydeRetriever {

    private final ChatClient chatClient;
    private final VectorStore vectorStore;

    private static final String HYDE_PROMPT = """
        다음 질문에 대한 가상의 문서 단락을 작성하세요.
        사실 여부는 중요하지 않고, 실제 문서에 있을 법한 문체와
        용어를 사용하는 것이 중요합니다. 3~4문장으로 작성하세요.

        질문: {question}
        """;

    public List<Document> retrieve(String question, int topK) {
        String hypothetical = chatClient.prompt()
            .user(u -> u.text(HYDE_PROMPT).param("question", question))
            .call().content();

        // 질문이 아니라 '가상 답변'으로 검색한다
        return vectorStore.similaritySearch(
            SearchRequest.builder().query(hypothetical).topK(topK).build()
        );
    }
}

8.4 Reranking

문제: 벡터 검색(bi-encoder)은 빠르지만 정밀도가 낮다. Cross-encoder는 정확하지만 느리다.

해법: 벡터 검색으로 20개를 빠르게 뽑고, cross-encoder로 5개를 정밀하게 고른다.

@Service
@RequiredArgsConstructor
public class RerankingService {

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    private static final String RERANK_PROMPT = """
        질문과 문서의 관련성을 0~10점으로 평가하세요.
        숫자만 출력하세요.

        질문: {question}

        문서:
        {document}
        """;

    public List<Document> retrieveAndRerank(String question, int candidates, int finalK) {
        // 1단계: 넓게 검색
        List<Document> pool = vectorStore.similaritySearch(
            SearchRequest.builder().query(question).topK(candidates).build()
        );

        // 2단계: 병렬로 재점수화
        List<ScoredDocument> scored = pool.parallelStream()
            .map(doc -> new ScoredDocument(doc, scoreRelevance(question, doc)))
            .sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
            .limit(finalK)
            .toList();

        return scored.stream().map(ScoredDocument::document).toList();
    }

    private double scoreRelevance(String question, Document doc) {
        try {
            String raw = chatClient.prompt()
                .user(u -> u.text(RERANK_PROMPT)
                            .param("question", question)
                            .param("document", doc.getText()))
                .call().content().trim();
            return Double.parseDouble(raw.replaceAll("[^0-9.]", ""));
        } catch (Exception e) {
            return 0.0;   // 파싱 실패 시 최하위로
        }
    }

    private record ScoredDocument(Document document, double score) {}
}

운영에서는 LLM 대신 전용 리랭커(Cohere Rerank, BGE-reranker 등)를 쓰는 게 비용·레이턴시 면에서 훨씬 유리하다. 위 코드는 원리 이해용이다.

8.5 Parent Document Retrieval

문제: 검색 정확도를 위해서는 청크가 작아야 하고, 답변 품질을 위해서는 컨텍스트가 커야 한다. 상충한다.

해법: 작은 청크로 검색하고, 그 청크가 속한 큰 문서를 LLM에 넘긴다.

@Service
@RequiredArgsConstructor
public class ParentDocumentRetriever {

    private final VectorStore vectorStore;
    private final ParentDocumentRepository parentRepo;  // 원본 저장용 (Redis/RDB)

    /** 인덱싱: 부모는 별도 저장, 자식만 벡터화 */
    public void index(Document parent) {
        String parentId = UUID.randomUUID().toString();
        parentRepo.save(parentId, parent.getText());

        TokenTextSplitter smallSplitter = new TokenTextSplitter(200, 100, 5, 10000, true);
        List<Document> children = smallSplitter.apply(List.of(parent));

        children.forEach(c -> c.getMetadata().put("parent_id", parentId));
        vectorStore.add(children);
    }

    /** 검색: 자식으로 찾고 부모를 반환 */
    public List<Document> retrieve(String query, int topK) {
        List<Document> children = vectorStore.similaritySearch(
            SearchRequest.builder().query(query).topK(topK * 3).build()
        );

        // 부모 ID 기준 중복 제거 (한 부모에서 여러 자식이 걸릴 수 있음)
        return children.stream()
            .map(c -> (String) c.getMetadata().get("parent_id"))
            .distinct()
            .limit(topK)
            .map(pid -> new Document(parentRepo.findById(pid), Map.of("parent_id", pid)))
            .toList();
    }
}

9. Spring AI 1.0의 RAG Advisor 방식

위 코드를 전부 손으로 짤 필요는 없다. Spring AI 1.0은 Advisor 추상화로 RAG 파이프라인을 선언적으로 조립할 수 있다.

9.1 가장 간단한 형태

@Configuration
public class ChatClientConfig {

    @Bean
    public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
        return builder
            .defaultAdvisors(
                QuestionAnswerAdvisor.builder(vectorStore)
                    .searchRequest(SearchRequest.builder()
                        .topK(5)
                        .similarityThreshold(0.7)
                        .build())
                    .build()
            )
            .build();
    }
}

이제 그냥 호출하면 검색·증강이 자동으로 붙는다.

String answer = ragChatClient.prompt()
    .user("Dooray 웹훅 재시도 정책이 어떻게 되나요?")
    .call()
    .content();

9.2 모듈러 RAG (세밀한 제어)

RetrievalAugmentationAdvisor를 쓰면 각 단계를 컴포넌트로 갈아끼울 수 있다.

@Bean
public Advisor modularRagAdvisor(VectorStore vectorStore, ChatClient.Builder builder) {

    ChatClient.Builder rewriteClient = builder.clone();

    return RetrievalAugmentationAdvisor.builder()

        // ① 질의 전처리 — 대화 맥락 반영
        .queryTransformers(
            RewriteQueryTransformer.builder()
                .chatClientBuilder(rewriteClient)
                .build()
        )

        // ② 질의 확장 — 여러 변형 생성
        .queryExpander(
            MultiQueryExpander.builder()
                .chatClientBuilder(rewriteClient)
                .numberOfQueries(3)
                .build()
        )

        // ③ 검색
        .documentRetriever(
            VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(8)
                .similarityThreshold(0.65)
                .build()
        )

        // ④ 후처리 — 문서 없을 때 처리
        .queryAugmenter(
            ContextualQueryAugmenter.builder()
                .allowEmptyContext(false)   // 빈 컨텍스트면 답변 거부
                .build()
        )
        .build();
}

각 인터페이스를 직접 구현해서 끼워 넣을 수도 있다.

@Component
public class HybridDocumentRetriever implements DocumentRetriever {

    private final HybridSearchService hybridSearch;

    @Override
    public List<Document> retrieve(Query query) {
        return hybridSearch.hybridSearch(query.text(), 5);
    }
}

9.3 대화 메모리와 함께 쓰기

@Bean
public ChatClient conversationalRagClient(
        ChatClient.Builder builder,
        VectorStore vectorStore,
        ChatMemory chatMemory) {

    return builder
        .defaultAdvisors(
            MessageChatMemoryAdvisor.builder(chatMemory).build(),   // 대화 이력
            QuestionAnswerAdvisor.builder(vectorStore).build()      // RAG
        )
        .build();
}

호출 시 대화 ID를 넘긴다.

String answer = conversationalRagClient.prompt()
    .user(question)
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
    .call()
    .content();

Redis 기반 메모리 구현:

@Bean
public ChatMemory chatMemory(RedisTemplate<String, String> redisTemplate) {
    return MessageWindowChatMemory.builder()
        .chatMemoryRepository(new RedisChatMemoryRepository(redisTemplate))
        .maxMessages(20)   // 최근 20개 메시지만 유지
        .build();
}

10. SSE 스트리밍 응답

RAG는 검색 + 생성이라 응답이 느리다. 첫 토큰이 나오자마자 흘려보내야 체감 속도가 산다.

@RestController
@RequestMapping("/api/rag")
@RequiredArgsConstructor
public class RagStreamController {

    private final ChatClient ragChatClient;
    private final RetrievalService retrievalService;

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> stream(@RequestParam String query) {

        // 1) 검색은 동기적으로 먼저 — 출처를 즉시 내려준다
        List<Document> docs = retrievalService.retrieve(query);

        Flux<ServerSentEvent<String>> citations = Flux.just(
            ServerSentEvent.<String>builder()
                .event("citations")
                .data(toJson(docs.stream().map(Citation::from).toList()))
                .build()
        );

        // 2) 생성은 스트리밍
        Flux<ServerSentEvent<String>> tokens = ragChatClient.prompt()
            .user(query)
            .stream()
            .content()
            .map(chunk -> ServerSentEvent.<String>builder()
                .event("token")
                .data(chunk)
                .build());

        // 3) 종료 시그널
        Flux<ServerSentEvent<String>> done = Flux.just(
            ServerSentEvent.<String>builder().event("done").data("").build()
        );

        return Flux.concat(citations, tokens, done)
            .timeout(Duration.ofSeconds(60))
            .onErrorResume(e -> Flux.just(
                ServerSentEvent.<String>builder()
                    .event("error")
                    .data(e.getMessage())
                    .build()
            ));
    }
}

클라이언트 측:

const es = new EventSource(`/api/rag/stream?query=${encodeURIComponent(q)}`);

es.addEventListener('citations', e => renderSources(JSON.parse(e.data)));
es.addEventListener('token',     e => appendToAnswer(e.data));
es.addEventListener('done',      () => es.close());
es.addEventListener('error',     e => { showError(e.data); es.close(); });

11. 평가와 모니터링

RAG는 "느낌상 잘 되는 것 같다"로 운영하면 반드시 망가진다. 최소한 아래 지표는 재야 한다.

11.1 핵심 지표

지표측정 대상정의
Context Recall검색정답에 필요한 문서를 실제로 가져왔는가
Context Precision검색가져온 문서 중 실제로 쓸모 있는 비율
Faithfulness생성답변이 컨텍스트에 근거하는가 (환각 여부)
Answer Relevance생성답변이 질문에 실제로 답하는가

검색이 실패하면 생성은 무조건 실패한다. 문제가 생기면 검색 지표부터 봐야 한다.

11.2 LLM-as-Judge 평가

@Service
@RequiredArgsConstructor
public class RagEvaluator {

    private final ChatClient judgeClient;

    private static final String FAITHFULNESS_PROMPT = """
        아래 답변이 오직 주어진 컨텍스트에만 근거하는지 평가하세요.

        컨텍스트:
        {context}

        답변:
        {answer}

        컨텍스트에 없는 주장이 하나라도 있으면 "FAIL",
        모든 주장이 컨텍스트로 뒷받침되면 "PASS"를 출력하고,
        다음 줄에 이유를 한 문장으로 쓰세요.
        """;

    public EvalResult evaluateFaithfulness(String answer, List<Document> context) {
        String contextText = context.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n---\n"));

        String verdict = judgeClient.prompt()
            .user(u -> u.text(FAITHFULNESS_PROMPT)
                        .param("context", contextText)
                        .param("answer", answer))
            .call().content();

        boolean passed = verdict.trim().startsWith("PASS");
        return new EvalResult(passed, verdict);
    }
}

11.3 골든 데이터셋 기반 회귀 테스트

@SpringBootTest
class RagRegressionTest {

    @Autowired RagChatService ragService;
    @Autowired RagEvaluator evaluator;

    record GoldenCase(String question, List<String> expectedDocIds) {}

    static List<GoldenCase> goldenSet() {
        return List.of(
            new GoldenCase("웹훅 재시도 횟수는?", List.of("doc-webhook-policy")),
            new GoldenCase("API rate limit 정책", List.of("doc-api-limits"))
            // ... 최소 50개는 있어야 유의미하다
        );
    }

    @ParameterizedTest
    @MethodSource("goldenSet")
    void 검색_재현율_검증(GoldenCase gc) {
        var response = ragService.ask(new RagRequest(gc.question(), null, null));

        Set<String> retrieved = response.citations().stream()
            .map(Citation::documentId).collect(Collectors.toSet());

        // 기대 문서 중 몇 개를 실제로 가져왔는가
        long hits = gc.expectedDocIds().stream().filter(retrieved::contains).count();
        double recall = (double) hits / gc.expectedDocIds().size();

        assertThat(recall).isGreaterThanOrEqualTo(0.8);
    }
}

11.4 관측 가능성 (Observability)

@Aspect
@Component
@RequiredArgsConstructor
public class RagMetricsAspect {

    private final MeterRegistry meterRegistry;

    @Around("execution(* com.example.rag.RagChatService.ask(..))")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        Timer.Sample sample = Timer.start(meterRegistry);
        String outcome = "success";
        try {
            Object result = pjp.proceed();
            if (result instanceof RagResponse r && r.citations().isEmpty()) {
                outcome = "no_context";
                meterRegistry.counter("rag.no_context").increment();
            }
            return result;
        } catch (Exception e) {
            outcome = "error";
            throw e;
        } finally {
            sample.stop(Timer.builder("rag.request.duration")
                .tag("outcome", outcome)
                .register(meterRegistry));
        }
    }
}

로깅해야 할 것들:

  • 질문 원문 / 재작성된 쿼리
  • 검색된 문서 ID와 유사도 점수
  • 최종 프롬프트 토큰 수
  • 응답 토큰 수 및 비용
  • 단계별 레이턴시 (검색 / 생성 분리)
  • 사용자 피드백 (👍/👎)

특히 "검색 결과가 비어 있던 질문 목록" 은 인덱싱 갭을 찾는 최고의 신호다. 주기적으로 뽑아보면 어떤 문서가 빠져 있는지 바로 보인다.


12. 실무 함정 체크리스트

인덱싱

  • 임베딩 모델을 바꾸면 전체 재인덱싱이 필요하다. 차원이 같아도 벡터 공간이 다르다.
  • 청크에 메타데이터를 충분히 넣었는가. 나중에 추가하려면 재인덱싱뿐이다.
  • 표(table)와 코드 블록이 청킹 과정에서 잘려나가지 않는가. 표 중간이 잘리면 의미가 완전히 사라진다.
  • 스캔 PDF는 OCR이 필요하다. 텍스트 레이어 없는 PDF를 넣으면 빈 문서가 들어간다.
  • 문서 삭제/갱신 시 벡터도 같이 지워지는가. 고아 벡터가 검색을 오염시킨다.

검색

  • similarityThreshold를 실측 없이 설정하지 마라. 모델·도메인마다 적정값이 다르다. 0.7이 어떤 모델에선 관대하고 어떤 모델에선 가혹하다.
  • 고유명사·에러코드·버전명 질의가 잘 되는가. 안 되면 하이브리드 검색이 필요하다.
  • topK를 늘리면 재현율은 오르지만 정밀도와 비용이 나빠진다. 리랭킹 없이 topK를 20으로 올리는 건 대개 악수다.
  • 한국어 형태소 분석 없이 full-text search를 쓰고 있지는 않은가.

생성

  • temperature를 0.1~0.3으로 낮췄는가. RAG는 창작이 아니다.
  • "모르면 모른다고 하라"는 지침이 프롬프트에 있는가.
  • 컨텍스트가 비었을 때 LLM을 호출하지 않고 조기 반환하는가. (비용 낭비 + 환각 유발)
  • 출처를 사용자에게 노출하는가. 검증 가능성이 신뢰의 핵심이다.

운영

  • 임베딩 API 호출에 재시도와 rate limit 백오프가 있는가.
  • 대량 인덱싱이 사용자 요청 트래픽을 방해하지 않는가. (별도 큐/배치로 분리)
  • 질문에 PII가 포함될 때 로깅 마스킹이 되는가.
  • 벡터 DB 커넥션 풀 크기가 적절한가. HNSW 검색은 CPU를 꽤 쓴다.
  • 인덱스 크기가 커지면 ef_search 파라미터 튜닝이 필요하다. (기본 40, 높이면 정확↑ 속도↓)
-- 세션 단위로 검색 정확도/속도 트레이드오프 조정
SET hnsw.ef_search = 100;

부록: 최소 실행 예제

전체를 한 파일로 압축한 버전. 이해 확인용.

@SpringBootApplication
public class MinimalRagApplication {

    public static void main(String[] args) {
        SpringApplication.run(MinimalRagApplication.class, args);
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore) {
        return builder
            .defaultSystem("주어진 컨텍스트만 사용해 한국어로 답하세요. 모르면 모른다고 하세요.")
            .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore).build())
            .build();
    }

    @Bean
    ApplicationRunner seedData(VectorStore vectorStore) {
        return args -> {
            vectorStore.add(List.of(
                new Document("웹훅 재시도는 최대 3회이며 지수 백오프를 적용한다.",
                             Map.of("source", "webhook-guide")),
                new Document("API rate limit은 분당 600회이며 초과 시 429를 반환한다.",
                             Map.of("source", "api-guide"))
            ));
        };
    }

    @RestController
    @RequiredArgsConstructor
    static class Api {
        private final ChatClient chatClient;

        @GetMapping("/ask")
        String ask(@RequestParam String q) {
            return chatClient.prompt().user(q).call().content();
        }
    }
}
curl "http://localhost:8080/ask?q=웹훅 재시도 몇 번 하나요"
# → 웹훅 재시도는 최대 3회 수행되며, 지수 백오프가 적용됩니다.

학습 순서 제안

  1. 부록의 최소 예제를 먼저 돌린다. QuestionAnswerAdvisor 한 줄로 RAG가 도는 걸 눈으로 본다.
  2. Advisor를 걷어내고 4~7장을 손으로 구현한다. 검색·프롬프트·생성이 각각 무슨 일을 하는지 몸으로 익힌다.
  3. 자기 문서 100개를 넣고 골든셋 20개를 만든다. 여기서 처음으로 "청킹이 중요하다"는 말이 체감된다.
  4. 검색 실패 케이스를 분석하고 8장 기법을 하나씩 적용한다. 무작정 다 넣지 말고, 실패 유형에 맞는 것만 고른다.
  5. 11장 지표를 붙이고 개선 전후를 수치로 비교한다.

4번에서 나온 "실패 유형 → 적용 기법 → 지표 개선폭" 기록이 곧 포트폴리오이자 면접에서 말할 수 있는 이야기가 된다.

profile
I'm the best

0개의 댓글