Spring AI

RAG(검색 증강 생성)

LLM에게 우리 회사 규정·제품 매뉴얼·사내 문서를 묻으면 모른다고 하거나, 그럴듯하게 지어낸다(환각). RAG(Retrieval-Augmented Generation) 는 질문과 관련된 문서를 먼저 검색해 프롬프트에 끼워 넣고, LLM이 그 근거를 바탕으로 답하게 하는 기법이다.

이 편에서 만드는 것

문서를 적재하는 ETL과, 인메모리 벡터 저장소 기반 RAG 질의(/rag/ingest · /rag/search · /rag/ask).


1. RAG의 두 단계

flowchart TB
    subgraph 적재["① 적재(ETL) — 미리 준비"]
      D["문서"] --> SP["청킹<br/>(TokenTextSplitter)"]
      SP --> EM["임베딩<br/>(text-embedding-3-small)"]
      EM --> VS[("벡터 저장소")]
    end
    subgraph 질의["② 질의 — 실행 시점"]
      Q["질문"] --> EMQ["질문 임베딩"]
      EMQ --> SR["유사 문서 검색"]
      VS --> SR
      SR --> CTX["검색된 문서를<br/>프롬프트에 주입"]
      CTX --> LLM --> A["근거 기반 답변"]
    end

개념

설명

임베딩(Embedding)

텍스트를 의미를 담은 숫자 벡터로 변환. 의미가 비슷하면 벡터도 가깝다

벡터 저장소

벡터를 저장하고 "가장 가까운 것"을 빠르게 찾아 주는 DB

청킹(Chunking)

긴 문서를 검색·주입하기 좋은 작은 조각으로 자르는 것


2. 벡터 저장소 설정

실습에서는 별도 DB 없이 메모리에 보관하는 SimpleVectorStore를 쓴다. 텍스트를 벡터로 바꿀 EmbeddingModel(OpenAI)을 주입해 빈으로 등록한다.

@Configuration
public class RagConfig {

    @Bean
    public SimpleVectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

운영에서는 pgvector로 교체

SimpleVectorStore는 애플리케이션을 끄면 사라진다. 운영에서는 PgVectorStore(PostgreSQL)·Redis·Chroma 등 영속 저장소로 이 빈만 바꾸면 된다. 검색·적재 코드는 VectorStore 인터페이스로 짜므로 그대로 유지된다.


3. 적재(ETL) — 문서를 벡터로

원문을 Document로 감싸고, TokenTextSplitter로 청킹한 뒤, vectorStore.add()로 적재한다. add() 내부에서 임베딩까지 자동으로 일어난다.

private final TokenTextSplitter splitter = new TokenTextSplitter();

public int ingest(String text, String source) {
    Document doc = new Document(text, Map.of("source", source));  // 메타데이터 부착
    List<Document> chunks = splitter.apply(List.of(doc));         // Transform: 청킹
    vectorStore.add(chunks);                                      // Load: 임베딩 + 저장
    return chunks.size();
}

Extract-Transform-Load

RAG의 적재는 데이터 파이프라인의 ETL과 같다. Extract(문서 읽기) → Transform(청킹·메타데이터) → Load(벡터 저장). PDF·워드·HTML은 PagePdfDocumentReader, TikaDocumentReader, JsoupDocumentReader 같은 리더로 읽어 같은 흐름에 태운다.


4. 검색 확인 — RAG의 '검색' 부분만 보기

LLM에 넘기기 전에, 질문과 유사한 문서가 실제로 잘 찾히는지 먼저 확인하면 디버깅이 쉽다.

public List<String> search(String question) {
    return vectorStore.similaritySearch(SearchRequest.builder()
                    .query(question)
                    .topK(3)            // 가장 가까운 3개
                    .build())
            .stream().map(Document::getText).toList();
}
curl -G "http://localhost:8080/rag/search" --data-urlencode "question=주차"
# ["구름카페 이용 안내. ... 주차는 지하 1층에 2시간 무료 ..."]

5. RAG 질의 — QuestionAnswerAdvisor

QuestionAnswerAdvisor를 붙이면 검색 → 컨텍스트 주입 → 답변 생성을 한 번에 처리한다. .advisors(...)로 물려주기만 하면 된다.

public String ask(String question) {
    QuestionAnswerAdvisor ragAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
            .searchRequest(SearchRequest.builder()
                    .topK(3)
                    .similarityThreshold(0.3)   // 유사도 0.3 미만은 버림
                    .build())
            .build();
    return chatClient.prompt()
            .advisors(ragAdvisor)   // ← 검색→주입을 어드바이저가 자동 처리
            .user(question)
            .call()
            .content();
}

'구름카페 안내문'을 미리 적재해 두고 질문하면:

curl -G "http://localhost:8080/rag/ask" --data-urlencode "question=주차 무료로 몇 시간 되나요?"
# 주차는 2시간 무료이며, 음료 구매 시 1시간이 추가되어 최대 3시간까지 가능합니다.

curl -G "http://localhost:8080/rag/ask" --data-urlencode "question=서울 인구는 몇 명이야?"
# 죄송하지만 제공된 정보에는 서울 인구에 대한 내용이 없어 알려드릴 수 없습니다.

문서에 있는 정보는 근거를 조합해 답하고(2시간+1시간=3시간), 문서에 없는 질문은 지어내지 않고 모른다고 한다. 이것이 RAG의 핵심 가치다.

similarityThreshold와 topK

topK는 가져올 문서 수, similarityThreshold는 "이 정도는 비슷해야 채택" 하는 하한선이다. 너무 낮으면 관련 없는 문서가, 너무 높으면 정작 필요한 문서가 빠질 수 있어 데이터에 맞춰 조정한다.


정리

  • RAG = 검색(우리 데이터에서 관련 문서 찾기) + 생성(그 근거로 LLM이 답하기).

  • 적재는 문서 → 청킹 → 임베딩 → 벡터 저장(ETL) 흐름.

  • 질의는 QuestionAnswerAdvisor 하나로 검색·주입·생성을 자동화.

  • 저장소는 VectorStore 인터페이스로 추상화돼, 실습용 SimpleVectorStore를 운영용 PgVectorStore로 바꿔 끼우기 쉽다.

➡️ 다음 편: 멀티모달 - 이미지

마지막 수정 2026년 10월 22일