
목표: "RAG가 실제로 어떤 코드로 굴러가는가"를 인덱싱 → 검색 → 증강 → 생성 전 구간에 걸쳐 실행 가능한 코드 레벨로 이해한다.
LLM 단독으로 사내 문서 질의응답을 시키면 세 가지 문제가 생긴다.
| 문제 | 설명 | RAG의 해법 |
|---|---|---|
| 지식 컷오프 | 학습 시점 이후 정보를 모름 | 최신 문서를 런타임에 주입 |
| 사내 데이터 부재 | 비공개 문서는 애초에 학습에 없음 | 자체 벡터 DB에서 검색 |
| 환각(Hallucination) | 모르는 걸 그럴듯하게 지어냄 | 근거 문서 밖 답변을 프롬프트로 차단 + 출처 표기 |
핵심 아이디어는 단순하다.
"LLM에게 시험 문제를 주기 전에, 오픈북 교재의 관련 페이지를 찢어서 같이 건네준다."
파인튜닝과의 차이:
| 구분 | 파인튜닝 | RAG |
|---|---|---|
| 지식 갱신 | 재학습 필요 (비쌈) | 문서만 다시 인덱싱 (쌈) |
| 출처 추적 | 불가능 | 가능 (citation) |
| 적합한 용도 | 말투·형식·태스크 학습 | 사실 기반 지식 주입 |
실무에서는 둘 다 쓰지만, "사실을 알려주고 싶으면 RAG, 스타일을 가르치고 싶으면 파인튜닝" 이 기본 판단 기준이다.
┌─────────────────── 오프라인: 인덱싱 파이프라인 ───────────────────┐
│ │
│ 원본 문서 문서 로딩 청크 분할 임베딩 │
│ (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 ──▶ 답변 + 출처 │
└───────────────────────────────────────────────────────────────────┘
용어 정리:
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'
}
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는 임베딩 모델 출력 차원과 반드시 일치해야 한다. 모델을 바꾸면 기존 벡터는 전부 무효가 되므로 재인덱싱이 필수다.
-- 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
| HNSW | IVFFlat | |
|---|---|---|
| 검색 속도 | 빠름 | 보통 |
| 재현율(recall) | 높음 | 중간 |
| 인덱스 빌드 | 느림, 메모리 많이 씀 | 빠름 |
| 데이터 추가 | 실시간 반영 양호 | 주기적 REINDEX 필요 |
문서 수가 100만 미만이고 실시간 인입이 있으면 HNSW가 무난하다.
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:
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개월만" 같은 필터를 걸려면 지금 넣어둬야 한다. 나중에 추가하려면 재인덱싱 외엔 방법이 없다.
가장 과소평가되는 단계다. 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&A | 200~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절 환불 정책"이라는 맥락이 본문에 없으면, 본문에 "환불"이라는 단어가 안 나올 경우 검색이 안 된다. 제목을 심어두면 그 신호가 벡터에 반영된다.
@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)
같은 문서를 두 번 넣으면 검색 결과가 중복으로 오염된다.
@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
);
}
}
@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(유클리드) | 절대적 거리가 의미 있을 때 |
<=> | 코사인 | 텍스트 임베딩 기본값 |
<#> | 내적(음수) | 정규화된 벡터에서 코사인과 동등, 더 빠름 |
벡터 검색 전에 후보군을 좁히면 정확도와 속도가 동시에 올라간다.
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
벡터 검색의 약점: 정확한 고유명사·에러코드·제품명을 놓친다.
예를 들어 "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 설정은 공백 단위 토크나이징이라 한계가 있다.
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"));
}
}
검색된 문서를 무조건 다 넣으면 토큰 한도를 넘거나, 비용이 폭증하거나, "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);
}
}
@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")
);
}
}
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
) {}
@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));
}
}
기본 RAG(naive RAG)로 안 풀리는 케이스들이 있다. 대표적인 개선 기법 다섯 가지.
문제: 사용자가 "그거 어떻게 해?"처럼 대화 맥락에 의존해 질문한다. 이대로 임베딩하면 검색이 안 된다.
@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 웹훅 인증 설정 방법"
하나의 질문을 여러 각도로 변형해서 검색 재현율을 높인다.
@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());
}
}
문제: 질문과 답변 문서는 문체가 다르다. "환불은 어떻게 하나요?"와 "환불 정책: 구매 후 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()
);
}
}
문제: 벡터 검색(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 등)를 쓰는 게 비용·레이턴시 면에서 훨씬 유리하다. 위 코드는 원리 이해용이다.
문제: 검색 정확도를 위해서는 청크가 작아야 하고, 답변 품질을 위해서는 컨텍스트가 커야 한다. 상충한다.
해법: 작은 청크로 검색하고, 그 청크가 속한 큰 문서를 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();
}
}
위 코드를 전부 손으로 짤 필요는 없다. Spring AI 1.0은 Advisor 추상화로 RAG 파이프라인을 선언적으로 조립할 수 있다.
@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();
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);
}
}
@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();
}
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(); });
RAG는 "느낌상 잘 되는 것 같다"로 운영하면 반드시 망가진다. 최소한 아래 지표는 재야 한다.
| 지표 | 측정 대상 | 정의 |
|---|---|---|
| Context Recall | 검색 | 정답에 필요한 문서를 실제로 가져왔는가 |
| Context Precision | 검색 | 가져온 문서 중 실제로 쓸모 있는 비율 |
| Faithfulness | 생성 | 답변이 컨텍스트에 근거하는가 (환각 여부) |
| Answer Relevance | 생성 | 답변이 질문에 실제로 답하는가 |
검색이 실패하면 생성은 무조건 실패한다. 문제가 생기면 검색 지표부터 봐야 한다.
@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);
}
}
@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);
}
}
@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));
}
}
}
로깅해야 할 것들:
특히 "검색 결과가 비어 있던 질문 목록" 은 인덱싱 갭을 찾는 최고의 신호다. 주기적으로 뽑아보면 어떤 문서가 빠져 있는지 바로 보인다.
similarityThreshold를 실측 없이 설정하지 마라. 모델·도메인마다 적정값이 다르다. 0.7이 어떤 모델에선 관대하고 어떤 모델에선 가혹하다.temperature를 0.1~0.3으로 낮췄는가. RAG는 창작이 아니다.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회 수행되며, 지수 백오프가 적용됩니다.
QuestionAnswerAdvisor 한 줄로 RAG가 도는 걸 눈으로 본다.4번에서 나온 "실패 유형 → 적용 기법 → 지표 개선폭" 기록이 곧 포트폴리오이자 면접에서 말할 수 있는 이야기가 된다.