RAG PDF 분석 서비스 'DocWeave' 개발 과정에서의 트러블슈팅
이번 글에서는 문서 분석 서비스 ‘DocWeave’를 개발하며 RAG의 검색 품질을 개선했던 경험을 공유하고자 합니다. Llama 3.2 모델을 도입하여 사용자 문서 기반의 질의응답 기능을 구현했으나, 모델 자체의 성능보다는 모델에게 전달할 Context를 찾는 과정에서 병목이 발생했습니다.
처음에는 단순히 문서를 일정 크기로 자르는 방식(Naive Chunking)으로 시작했습니다. 하지만 서비스의 정확도를 높이기 위해 여러 테스트를 진행하면서, "검색이 잘 되는 덩어리"와 "답변하기 좋은 덩어리"의 크기가 서로 다르다는 딜레마에 빠지게 되었습니다.
이 글에서는 제가 직접 겪은 시행착오와, 이를 Parent-Child Chunking 전략을 통해 해결하며 RAG 파이프라인의 성능을 최적화한 경험을 소개하고자 합니다.
가장 처음 설계했던 문서 처리 구조는 매우 단순하고 직관적이었습니다. Spring AI의 TokenTextSplitter를 사용하여 문서를 1000 토큰 단위로 잘라 벡터 데이터베이스에 저장하는 방식이었습니다.
// 초기 코드: 1000 토큰 단위 절단
TokenTextSplitter splitter = new TokenTextSplitter(); // default 800~1000
vectorStore.add(splitter.apply(documents));
하지만 다양한 형태의 문서로 테스트를 진행하자 예상치 못한 문제들이 발생하기 시작했습니다.
결국 청크의 크기가 너무 커서 의미가 희석되는 현상(Semantic Dilution)이 근본적인 문제였습니다.
의미 희석과 문맥 절단 문제를 해결하기 위해, 청크 사이즈를 줄이고 Overlap(중복 구간)을 도입하는 시도를 했습니다.
확실히 문장이 중간에 잘리는 일은 줄었고, 청크 사이즈가 작아지니 검색의 정확도(Precision)는 올라갔습니다. 하지만 여기서 트레이드오프가 발생했습니다.
작게 잘린 조각(400 토큰)을 그대로 LLM에게 던져주니, 이번엔 앞뒤 문맥이 부족해서 답변이 부실해지는 문제가 나타났습니다. 반대로 사이즈를 키우면 문맥은 풍부해지지만 검색이 잘 안 되는 딜레마가 반복되었습니다.
결과적으로 "검색은 작게(Small Chunk), 답변은 넓게(Large Context)" 가져가야 한다는 결론에 도달했습니다.
이 문제를 해결하기 위해 도입한 것은 Parent-Child Indexing 전략입니다. 원리는 검색용 데이터와 생성용 데이터를 분리하는 것입니다.
코드는 크게 문서 저장(Process) 로직과 검색(Retrieve) 로직으로 나뉩니다.
1. 문서 처리 및 저장
// 1. Parent Chunking (1000 토큰): 문맥 보존용
TokenTextSplitter parentSplitter = new TokenTextSplitter(1000, 100, 10, 1000, true);
List<Document> parentDocs = parentSplitter.apply(rawDocuments);
for (Document pDoc : parentDocs) {
// 2. Parent는 RDB(MySQL)에 저장하여 원본 텍스트 확보
DocContent savedParent = docContentRepository.save(DocContent.builder()
.content(pDoc.getText())
.pageNumber(pageNum)
.build());
// 3. Child Chunking (300 토큰): 벡터 검색용
TokenTextSplitter childSplitter = new TokenTextSplitter(300, 50, 10, 100, true);
List<Document> childDocs = childSplitter.apply(Collections.singletonList(pDoc));
// 4. Child에 Parent ID 매핑 후 벡터 스토어 저장
for (Document cDoc : childDocs) {
cDoc.getMetadata().put("parent_id", savedParent.getId()); // 핵심 연결 고리
childDocsToEmbed.add(cDoc);
}
}
vectorStore.add(childDocsToEmbed);
2. 검색 및 답변
검색 때는 벡터 스토어에서 Child를 찾지만, 정작 프롬프트에 주입하는 데이터는 RDB에서 조회한 Parent입니다.
// 1. 질문과 유사한 Child Chunk 검색 (작은 단위라 검색 정확도가 높음)
List<Document> similarChildren = vectorStore.similaritySearch(requestDto.getMessage());
// 2. 검색된 Child들의 Parent ID 추출
Set<Long> parentIds = similarChildren.stream()
.map(doc -> (Long) doc.getMetadata().get("parent_id"))
.collect(Collectors.toSet());
// 3. RDB에서 Parent 내용 조회 (충분한 문맥 확보)
List<DocContent> parentContents = docContentRepository.findAllByIdIn(new ArrayList<>(parentIds));
String context = parentContents.stream()
.map(DocContent::getContent)
.collect(Collectors.joining("\n\n"));
// 4. LLM 요청 (풍부한 문맥 제공)
Prompt prompt = template.create(Map.of("context", context, "message", requestDto.getMessage()));
이 구조로 변경한 후 체감 성능과 시스템의 안정성이 크게 향상되었습니다.
이번 경험을 통해 RAG 시스템 설계 시 고려해야 할 중요한 점을 배울 수 있었습니다.