Spring BootAIArchitectureDatabase

Spring AI + PGVector로 사내 문서 검색 RAG 파이프라인을 구현한 과정

사내 문서를 LLM이 검색하고 답변할 수 있도록 Spring AI와 PGVector 기반 RAG 파이프라인을 구축한 경험을 정리했습니다. 임베딩, 벡터 저장소, 검색 품질 개선까지 실무에서 겪은 시행착오를 담았습니다.

Srue2026년 5월 3일
Spring AI + PGVector로 사내 문서 검색 RAG 파이프라인을 구현한 과정

"사내 위키에서 원하는 내용을 못 찾겠다"는 이야기가 팀에서 반복적으로 나왔습니다. 온보딩 가이드, 장애 대응 매뉴얼, API 스펙 문서가 Confluence와 Notion에 흩어져 있었고, 키워드 검색만으로는 원하는 답을 찾기가 점점 어려워지고 있었습니다.

처음에는 Elasticsearch 같은 전문 검색 엔진을 붙이면 되겠다고 생각했습니다. 그런데 사람들이 원하는 건 "문서 목록"이 아니라 "질문에 대한 답변"이었습니다. "배포 롤백 절차가 어떻게 되지?"라고 물으면 해당 문서를 찾아서 핵심을 요약해주는 것. 결국 LLM이 필요한 영역이었습니다.

문제는 LLM에게 수백 개의 사내 문서를 전부 넘겨줄 수는 없다는 점이었습니다. 컨텍스트 윈도우에도 한계가 있고, 비용도 비쌌습니다. 그래서 RAG(Retrieval-Augmented Generation, 검색 증강 생성)라는 접근법에 도달했습니다. 질문과 관련된 문서 조각만 먼저 찾고, 그 조각을 LLM에게 맥락으로 넘겨서 답변을 생성하는 방식입니다.

RAG가 필요했던 이유

LLM은 학습 데이터에 포함되지 않은 내용에 대해서는 그럴듯하지만 틀린 답(Hallucination, 할루시네이션)을 만들어냅니다. 사내 문서는 당연히 학습 데이터에 없습니다. 그래서 "우리 회사 배포 절차"를 물으면, 일반적인 배포 절차를 지어내는 상황이 벌어집니다.

RAG의 핵심 아이디어는 단순합니다.

  1. 사내 문서를 작은 조각(chunk)으로 나눈다
  2. 각 조각을 벡터(임베딩)로 변환해서 저장한다
  3. 사용자 질문도 벡터로 변환한 뒤, 유사한 문서 조각을 검색한다
  4. 검색된 조각을 LLM 프롬프트에 첨부해서 답변을 생성한다

이전에 Spring AI로 LLM API를 연동할 때는 프롬프트 하나로 끝나는 단순한 구조였습니다. RAG는 거기에 "검색" 계층이 하나 더 얹어지는 셈입니다.

기술 스택 선택: 왜 PGVector였는가

벡터 저장소를 고를 때 후보가 여럿 있었습니다. Pinecone, Weaviate, Milvus 같은 전용 벡터 DB도 있었지만, 결국 PGVector를 골랐습니다.

항목전용 벡터 DB (Pinecone 등)PGVector (PostgreSQL 확장)
인프라별도 서비스 운영 필요기존 PostgreSQL에 확장 추가
학습 비용새로운 쿼리 문법, SDKSQL + 친숙한 JPA 연동
트랜잭션별도 관리기존 트랜잭션과 통합
규모대규모 벡터 검색에 최적화수십만 건까지는 충분

사내 문서가 수백 개 수준이었고, 이미 PostgreSQL을 쓰고 있었기 때문에 별도 인프라를 추가하지 않아도 된다는 점이 결정적이었습니다. 새로운 기술을 도입하는 것 자체가 비용이니까요.

PGVector 세팅

PostgreSQL에 PGVector 확장을 활성화하는 것부터 시작했습니다.

V1__add_pgvector_extension.sql
CREATE EXTENSION IF NOT EXISTS vector;

Spring AI는 PGVector용 스타터를 제공합니다. 의존성을 추가하면 VectorStore 빈이 자동 구성됩니다.

build.gradle
dependencies {
    implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter:1.0.0'
    implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0'
}
application.yml
spring:
  ai:
    vectorstore:
      pgvector:
        index-type: hnsw
        distance-type: cosine_distance
        dimensions: 1536
    openai:
      api-key: ${OPENAI_API_KEY}
      embedding:
        options:
          model: text-embedding-3-small

dimensions: 1536은 OpenAI의 text-embedding-3-small 모델이 생성하는 벡터 차원 수입니다. 임베딩 모델을 바꾸면 이 값도 함께 바꿔야 합니다. 처음에 이걸 맞추지 않아서 dimension mismatch 에러를 만났던 기억이 있습니다.

문서 수집과 청킹: 생각보다 까다로운 전처리

RAG에서 가장 많은 시간을 쏟은 건 LLM 연동이 아니라 문서 전처리였습니다. 사내 문서를 가져와서 적절한 크기로 자르는 과정이 검색 품질을 좌우했습니다.

문서 로더

Spring AI는 다양한 문서 형식을 읽을 수 있는 DocumentReader를 제공합니다.

DocumentIngestionService.java
@Service
@RequiredArgsConstructor
public class DocumentIngestionService {
 
    private final VectorStore vectorStore;
 
    public void ingestMarkdownFiles(Path directory) throws IOException {
        List<Document> allDocuments = new ArrayList<>();
 
        try (var paths = Files.walk(directory)) {
            List<Path> mdFiles = paths
                .filter(p -> p.toString().endsWith(".md"))
                .toList();
 
            for (Path file : mdFiles) {
                TextReader reader = new TextReader(new FileSystemResource(file));
                reader.getCustomMetadata().put("source", file.getFileName().toString());
                allDocuments.addAll(reader.read());
            }
        }
 
        // 청킹 후 벡터 저장소에 저장
        TokenTextSplitter splitter = new TokenTextSplitter(800, 200, 5, 10000, true);
        List<Document> chunks = splitter.apply(allDocuments);
 
        vectorStore.add(chunks);
    }
}

청킹 전략이 검색 품질을 결정했다

처음에는 단순히 글자 수 기준으로 500자씩 잘랐습니다. 결과는 처참했습니다. 문장 중간에서 잘리거나, 하나의 절차가 두 청크에 나뉘어서 맥락이 사라졌습니다.

결국 TokenTextSplitter의 파라미터를 여러 번 조정하며 실험했습니다.

파라미터첫 시도최종 설정이유
chunkSize500800한국어 문서는 토큰 대비 글자 수가 적어 더 넉넉하게
overlapSize0200청크 경계에서 맥락이 끊기는 문제 완화
minChunkSize15너무 짧은 청크(빈 줄, 제목만) 제거

오버랩(overlap)을 200 토큰으로 설정한 건 큰 차이를 만들었습니다. 이전 청크의 마지막 200토큰이 다음 청크 앞에 중복 포함되면서, 문단 경계에서 맥락이 유지됐습니다.

검색과 답변 생성

문서가 벡터 저장소에 들어간 뒤에는 질문을 받아 관련 문서를 검색하고, LLM에게 답변을 생성시키는 흐름을 만들었습니다.

RagService.java
@Service
@RequiredArgsConstructor
public class RagService {
 
    private final ChatClient chatClient;
    private final VectorStore vectorStore;
 
    @Value("classpath:prompts/rag-system.st")
    private Resource ragSystemPrompt;
 
    public String ask(String question) {
        // 1. 질문과 유사한 문서 조각 검색
        List<Document> relevantDocs = vectorStore.similaritySearch(
            SearchRequest.builder()
                .query(question)
                .topK(5)
                .similarityThreshold(0.7)
                .build()
        );
 
        // 2. 검색된 문서를 컨텍스트로 조합
        String context = relevantDocs.stream()
            .map(Document::getText)
            .collect(Collectors.joining("\n\n---\n\n"));
 
        // 3. LLM에게 컨텍스트와 함께 질문
        return chatClient.prompt()
            .system(s -> s.text(ragSystemPrompt)
                .param("context", context))
            .user(question)
            .call()
            .content();
    }
}
src/main/resources/prompts/rag-system.st
당신은 사내 문서 검색 도우미입니다.
아래 제공된 문서 내용만을 기반으로 질문에 답변해주세요.
문서에 없는 내용은 "해당 내용을 찾을 수 없습니다"라고 답해주세요.
답변 시 어떤 문서를 참고했는지 출처도 함께 알려주세요.
 
참고 문서:
{context}

시스템 프롬프트에서 "문서에 없는 내용은 답하지 마라"고 명시한 게 중요했습니다. 이 한 줄이 없으면 LLM이 자기 학습 데이터를 섞어서 답변하기 시작합니다. 이전에 Spring AI로 LLM을 연동할 때 배웠던 프롬프트 템플릿 분리도 여기서 그대로 활용했습니다.

유사도 임계값 조정

similarityThreshold(0.7)은 여러 번의 실험 끝에 정한 값입니다.

  • 0.5 이하: 관련 없는 문서까지 잡혀서 LLM이 혼란스러운 답변을 생성
  • 0.8 이상: 너무 엄격해서 관련 문서가 있는데도 "찾을 수 없다"고 답변
  • 0.65~0.75: 실무에서 가장 균형 잡힌 구간

이런 수치는 문서의 성격과 언어에 따라 다르기 때문에, 결국 직접 질문을 던져보며 조정하는 수밖에 없었습니다.

API 엔드포인트는 이전에 REST API 응답 구조를 설계했던 패턴을 따라 /api/rag/ask(질문)와 /api/rag/ingest(문서 수집)로 단순하게 구성했습니다. 문서 수집 엔드포인트는 @PreAuthorize("hasRole('ADMIN')")로 관리자 권한을 제한했습니다.

실무에서 부딪힌 문제들

한국어 임베딩 품질

OpenAI의 임베딩 모델은 영어에 최적화되어 있습니다. 한국어 문서에서는 같은 의미를 다르게 표현한 문장 간의 유사도가 기대보다 낮게 나왔습니다. 예를 들어 "서버 재시작 방법"과 "서버를 다시 켜는 절차"가 충분히 가까운 벡터로 매핑되지 않는 경우가 있었습니다.

완벽한 해결은 아니었지만, 두 가지로 개선했습니다.

  1. 문서 제목과 키워드를 메타데이터로 추가해서 검색 시 가중치 부여
  2. topK를 넉넉하게 잡아서(5개) 후보를 넓히고, 최종 판단은 LLM에게 위임

문서 갱신 시 벡터 동기화

사내 문서는 계속 바뀝니다. 문서가 수정되면 해당 문서의 기존 벡터를 삭제하고 새로 임베딩해야 합니다. 처음에는 전체를 매번 다시 임베딩했는데, 비용과 시간이 너무 들었습니다.

DocumentSyncService.java
public void syncDocument(String source, Path filePath) {
    // 기존 벡터 삭제
    vectorStore.delete(
        FilterExpressionBuilder.builder()
            .eq("source", source)
            .build()
    );
 
    // 새로 임베딩
    TextReader reader = new TextReader(new FileSystemResource(filePath));
    reader.getCustomMetadata().put("source", source);
 
    TokenTextSplitter splitter = new TokenTextSplitter(800, 200, 5, 10000, true);
    List<Document> chunks = splitter.apply(reader.read());
 
    vectorStore.add(chunks);
}

메타데이터에 source 필드를 넣어두고, 파일 단위로 삭제/재생성하는 방식으로 바꿨습니다. 인덱스를 추가하기 전에 조회 패턴을 먼저 확인했던 것처럼, 벡터 저장소에서도 "어떤 단위로 갱신할 것인가"를 먼저 정하는 게 중요했습니다.

임베딩 비용 관리

임베딩 API도 토큰 단위로 과금됩니다. 문서가 자주 바뀌면 비용이 누적되기 때문에, 파일의 해시값을 저장해두고 해시가 바뀐 문서만 재임베딩하도록 변경 감지를 추가했습니다. Redis 캐시를 붙일 때와 비슷한 발상이었습니다. 불필요한 작업을 줄이는 것이 결국 비용 관리의 핵심이었습니다.

Before & After 비교

파이프라인 도입 전후로 사내 문서 검색 경험이 어떻게 바뀌었는지 정리하면 이렇습니다.

항목기존 (키워드 검색)RAG 파이프라인 도입 후
검색 방식정확한 키워드 필요자연어 질문 가능
결과 형태문서 목록 나열질문에 대한 직접 답변 + 출처
여러 문서에 걸친 내용직접 종합해야 함LLM이 관련 조각을 종합해서 답변
문서를 못 찾았을 때빈 결과"해당 내용을 찾을 수 없다"고 명시
신규 입사자 온보딩선배에게 반복 질문챗봇으로 1차 해소

물론 한계도 있었습니다. 표나 이미지 중심의 문서는 텍스트로 변환하는 과정에서 정보가 손실됐고, 아주 최근에 추가된 문서는 임베딩 동기화 전까지 검색되지 않았습니다.

마무리

RAG 파이프라인을 직접 구현하면서 가장 크게 느낀 건, 이 시스템의 성능이 LLM의 능력보다 검색의 품질에 더 크게 좌우된다는 점이었습니다. 아무리 좋은 LLM을 써도 엉뚱한 문서 조각이 컨텍스트로 들어가면 답변 품질이 떨어집니다. 결국 청킹 전략, 임베딩 모델 선택, 유사도 임계값 같은 "검색 파이프라인의 기본기"가 전체 품질을 결정했습니다.

Spring AI와 PGVector 조합은 이미 PostgreSQL을 쓰고 있는 팀에게는 꽤 현실적인 선택이었습니다. 전용 벡터 DB를 새로 도입하는 것에 비해 운영 부담이 훨씬 적었고, Spring Boot의 익숙한 패턴 안에서 RAG를 구현할 수 있다는 점이 팀 내 진입 장벽을 낮춰줬습니다.

처음에는 "AI 기능이니까 뭔가 특별한 아키텍처가 필요하지 않을까"라고 생각했지만, 결국 데이터를 잘 정리하고, 적절히 캐싱하고, 외부 API 장애에 대비하는 것. 백엔드 개발자로서 해왔던 일들의 연장선이었습니다.