RAG 검색 품질 개선: Parent-Child Chunking

k_bell·2026년 1월 17일

DocWeave

목록 보기
5/5
post-thumbnail

RAG PDF 분석 서비스 'DocWeave' 개발 과정에서의 트러블슈팅

이번 글에서는 문서 분석 서비스 ‘DocWeave’를 개발하며 RAG의 검색 품질을 개선했던 경험을 공유하고자 합니다. Llama 3.2 모델을 도입하여 사용자 문서 기반의 질의응답 기능을 구현했으나, 모델 자체의 성능보다는 모델에게 전달할 Context를 찾는 과정에서 병목이 발생했습니다.

처음에는 단순히 문서를 일정 크기로 자르는 방식(Naive Chunking)으로 시작했습니다. 하지만 서비스의 정확도를 높이기 위해 여러 테스트를 진행하면서, "검색이 잘 되는 덩어리""답변하기 좋은 덩어리"의 크기가 서로 다르다는 딜레마에 빠지게 되었습니다.

이 글에서는 제가 직접 겪은 시행착오와, 이를 Parent-Child Chunking 전략을 통해 해결하며 RAG 파이프라인의 성능을 최적화한 경험을 소개하고자 합니다.

1. Naive Chunking의 한계

가장 처음 설계했던 문서 처리 구조는 매우 단순하고 직관적이었습니다. Spring AI의 TokenTextSplitter를 사용하여 문서를 1000 토큰 단위로 잘라 벡터 데이터베이스에 저장하는 방식이었습니다.

// 초기 코드: 1000 토큰 단위 절단
TokenTextSplitter splitter = new TokenTextSplitter(); // default 800~1000
vectorStore.add(splitter.apply(documents));

하지만 다양한 형태의 문서로 테스트를 진행하자 예상치 못한 문제들이 발생하기 시작했습니다.

  • 상황: 한 청크(1000 토큰) 내에 '계약 기간', '위약금', '면책 조항' 등 서로 다른 주제의 정보가 혼재되어 있습니다.
  • 문제: 사용자가 구체적인(예: "위약금") 질문을 던졌을 때, 해당 청크의 벡터 임베딩이 여러 주제로 뭉뚱그려져 있어 질문과의 유사도가 낮게 측정됩니다.
  • 결과: 답변에 필요한 핵심 정보가 포함된 청크가 검색 순위에서 밀려나고, LLM은 엉뚱한 답변을 하거나 정보를 찾지 못하는 상황이 발생합니다.

결국 청크의 크기가 너무 커서 의미가 희석되는 현상(Semantic Dilution)이 근본적인 문제였습니다.

2. Sliding Window와 트레이드오프(Trade-off)

의미 희석과 문맥 절단 문제를 해결하기 위해, 청크 사이즈를 줄이고 Overlap(중복 구간)을 도입하는 시도를 했습니다.

  • 설정: Chunk Size 1000 → 400, Overlap 200

확실히 문장이 중간에 잘리는 일은 줄었고, 청크 사이즈가 작아지니 검색의 정확도(Precision)는 올라갔습니다. 하지만 여기서 트레이드오프가 발생했습니다.

작게 잘린 조각(400 토큰)을 그대로 LLM에게 던져주니, 이번엔 앞뒤 문맥이 부족해서 답변이 부실해지는 문제가 나타났습니다. 반대로 사이즈를 키우면 문맥은 풍부해지지만 검색이 잘 안 되는 딜레마가 반복되었습니다.

결과적으로 "검색은 작게(Small Chunk), 답변은 넓게(Large Context)" 가져가야 한다는 결론에 도달했습니다.

3. Parent-Child Chunking으로 구조 분리

이 문제를 해결하기 위해 도입한 것은 Parent-Child Indexing 전략입니다. 원리는 검색용 데이터와 생성용 데이터를 분리하는 것입니다.

  1. Parent Chunk (대형): 문서를 크게(1000 토큰) 자릅니다. 이는 벡터화하지 않고 RDB에 원문 그대로 저장하여 Context 보존용으로 사용합니다.
  2. Child Chunk (소형): Parent를 다시 작게(300 토큰) 자릅니다. 이는 벡터 DB에 저장하여 정밀한 검색용으로 사용합니다.
  3. 검색 시: 질문과 유사한 Child를 찾되, 실제 LLM에게는 그 Child가 속한 Parent의 전체 내용을 넘겨줍니다.

구현 코드

코드는 크게 문서 저장(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()));

4. 최종 아키텍처 및 결과

이 구조로 변경한 후 체감 성능과 시스템의 안정성이 크게 향상되었습니다.

  • 검색 정확도: 300토큰 단위로 세밀하게 검색함으로써, 구체적인 수치나 짧은 문장을 놓치지 않고 잡아낼 수 있었습니다.
  • 답변 품질: LLM에게 1000토큰 분량의 앞뒤 Context(Parent)를 제공하니, 답변이 훨씬 자연스럽고 정확해졌습니다.

5. 결론

이번 경험을 통해 RAG 시스템 설계 시 고려해야 할 중요한 점을 배울 수 있었습니다.

  1. 단순히 문서를 잘라서 넣는 것만으로는 높은 품질의 검색을 보장할 수 없다.
  2. 검색(Retrieval)과 생성(Generation)에 필요한 데이터의 형태가 다르다는 점을 인지하고 설계를 분리해야 한다.
  3. Vector DB와 RDB를 적절히 혼합하여 사용함으로써, 정밀한 검색과 풍부한 Context를 확보할 수 있다.

0개의 댓글