이번 프로젝트에서는 이미지뿐만 아니라 PDF 파일을 RAG의 검색 대상 문서로 적재하는 과정을 구현했다.
PDF는 이미지처럼 그대로 Vector Store에 넣는 것이 아니라,
PDF 파일 → 페이지별 Document → Chunk → Vector Store 저장
과정을 거쳐야 한다.
이번 프로젝트에서 핵심적으로 사용한 기술은 다음과 같다.
MultipartFile을 이용한 PDF 업로드PagePdfDocumentReaderPdfDocumentReaderConfigTokenTextSplitterDocumentVectorStorevectorStore.add()를 통한 벡터 저장PDF 파일 자체는 RAG가 바로 검색할 수 있는 형태가 아니다.
따라서 업로드된 PDF를 먼저 Document 객체로 변환하고, 다시 검색하기 적절한 크기의 조각으로 나누어야 한다.
전체적인 흐름은 다음과 같다.
PDF 업로드
↓
MultipartFile
↓
InputStreamResource
↓
PagePdfDocumentReader
↓
페이지별 Document
↓
TokenTextSplitter
↓
여러 개의 Chunk
↓
metadata 추가
↓
VectorStore.add()
↓
벡터 DB 저장
이 과정에서 중요한 것은 PDF를 읽는 것과 PDF를 검색 가능한 단위로 만드는 것은 별개의 작업이라는 점이다.
PDF 파일은 MultipartFile로 전달받는다.
실제 프로젝트에서는 PDF 업로드를 처리하는 서비스에서 다음과 같은 형태로 파일을 받는다.
public int uploadPDF(MultipartFile file) {
validate(file);
...
}
여기서 MultipartFile은 사용자가 업로드한 파일을 Spring에서 표현하는 객체다.
따라서 서비스 입장에서는 다음과 같은 정보를 가지고 PDF를 처리할 수 있다.
MultipartFile
├─ 파일 이름
├─ Content-Type
├─ InputStream
└─ 파일 데이터
특히 PDF 내용을 읽기 위해서는 다음처럼 getInputStream()을 사용한다.
Resource resource =
new InputStreamResource(
file.getInputStream()
);
즉,
MultipartFile
↓
getInputStream()
↓
InputStreamResource
↓
PDF Reader
라는 형태로 PDF Reader가 읽을 수 있는 Resource로 변환한다.
PDF를 읽는 핵심 코드가 다음 부분이다.
PagePdfDocumentReader reader =
new PagePdfDocumentReader(
resource,
PdfDocumentReaderConfig.builder().build()
);
여기서 중요한 클래스가 PagePdfDocumentReader다.
이 Reader는 PDF를 읽어서 Spring AI의 Document 형태로 변환하는 역할을 한다.
그리고 실제로 PDF를 읽는 부분은 다음과 같다.
List<Document> pages = reader.read();
결과적으로 PDF 하나가 여러 개의 Document로 변환된다.
예를 들어 PDF가 5페이지라면 개념적으로 다음과 같은 형태가 된다.
PDF
├─ 1페이지 → Document
├─ 2페이지 → Document
├─ 3페이지 → Document
├─ 4페이지 → Document
└─ 5페이지 → Document
즉, 이 단계에서는 PDF를 페이지 단위의 Document로 변환한다.
페이지 단위로 만들어졌다고 해서 바로 Vector Store에 저장하는 것은 아니다.
프로젝트에서는 다음 코드를 사용한다.
TokenTextSplitter splitter =
TokenTextSplitter.builder().build();
List<Document> chunks =
splitter.apply(pages);
여기서 TokenTextSplitter가 하는 역할은 Document를 더 작은 텍스트 조각으로 나누는 것이다.
전체 과정을 보면:
PDF
↓
PagePdfDocumentReader
↓
pages
↓
TokenTextSplitter
↓
chunks
이다.
페이지 하나가 항상 검색하기 좋은 크기의 텍스트라는 보장이 없기 때문이다.
예를 들어 한 페이지에 매우 많은 내용이 있다면 하나의 Document로 저장했을 때 검색 단위가 너무 커질 수 있다.
그래서
페이지 단위 Document
↓
TokenTextSplitter
↓
작은 Chunk 여러 개
형태로 변환한다.
이렇게 만들어진 chunks가 실제 RAG 검색의 기본 단위가 된다.
이번 프로젝트에서 특히 눈여겨볼 부분이 이 코드다.
chunks.forEach(
chunk -> chunk.getMetadata()
.put(
"filename",
file.getOriginalFilename()
)
);
Chunk의 본문만 저장하는 것이 아니라 어떤 PDF에서 나온 내용인지 metadata로 저장한다.
예를 들어 사용자가
spring-ai-guide.pdf
를 업로드했다면 각각의 Chunk에 다음과 같은 metadata가 들어갈 수 있다.
Document
├─ text
│ └─ PDF에서 추출된 내용
│
└─ metadata
└─ filename = "spring-ai-guide.pdf"
이렇게 하면 나중에 검색 결과에서 해당 내용이 어떤 파일에서 나온 것인지 확인할 수 있는 기반을 만들 수 있다.
마지막으로 만들어진 Chunk들을 Vector Store에 저장한다.
vectorStore.add(chunks);
여기서 중요한 것은 chunks가 단순한 문자열 목록이 아니라는 점이다.
List<Document> chunks
형태로 만들어져 있다.
그리고 이 Document 목록을 VectorStore에 전달한다.
전체 PDF 적재 코드는 다음과 같은 구조다.
public int uploadPDF(MultipartFile file) {
validate(file);
try {
Resource resource =
new InputStreamResource(
file.getInputStream()
);
PagePdfDocumentReader reader =
new PagePdfDocumentReader(
resource,
PdfDocumentReaderConfig.builder().build()
);
List<Document> pages = reader.read();
TokenTextSplitter splitter =
TokenTextSplitter.builder().build();
List<Document> chunks =
splitter.apply(pages);
chunks.forEach(
chunk -> chunk.getMetadata()
.put(
"filename",
file.getOriginalFilename()
)
);
vectorStore.add(chunks);
return chunks.size();
} catch (Exception e) {
throw new IllegalArgumentException(e);
}
}
이 코드 하나에 PDF RAG의 적재 과정 전체가 들어있다.
| 코드 | 역할 |
|---|---|
MultipartFile file | 업로드된 PDF를 받음 |
file.getInputStream() | PDF 데이터를 읽을 수 있는 InputStream 획득 |
InputStreamResource | PDF Reader가 사용할 Resource 생성 |
PagePdfDocumentReader | PDF를 읽음 |
reader.read() | PDF를 Document 목록으로 변환 |
TokenTextSplitter | Document를 검색하기 적절한 Chunk로 분할 |
getMetadata().put() | 원본 PDF 파일명 저장 |
vectorStore.add(chunks) | Chunk를 Vector Store에 적재 |
chunks.size() | 생성된 Chunk 개수 반환 |
이번 구현을 공부하면서 PDF RAG를 단순히
PDF → Vector DB
라고 생각하면 안 된다.
실제로는 다음과 같은 여러 단계가 존재한다.
PDF
│
▼
PagePdfDocumentReader
│
▼
List<Document>
페이지 단위
│
▼
TokenTextSplitter
│
▼
List<Document>
Chunk 단위
│
▼
metadata 추가
│
▼
VectorStore.add()
│
▼
Vector Store
즉 RAG의 검색 품질을 결정하는 중요한 부분 중 하나가 문서를 어떻게 쪼개서 저장하느냐라는 것을 알 수 있다.
PDF 업로드 요청을 받는 Controller에서는 PDF 처리 자체를 직접 하지 않고 서비스의 uploadPDF()를 호출한다.
실제 프로젝트의 PDF 업로드 흐름에서는 서비스에서 다음 메서드를 사용한다.
uploadPDF(MultipartFile file)
그리고 PDF를 적재한 뒤 반환되는 값은 다음과 같다.
return chunks.size();
즉, 업로드된 PDF가 몇 개의 Chunk로 분리되었는지를 반환하도록 되어 있다.
예를 들어:
PDF 업로드
↓
100개의 Chunk 생성
↓
vectorStore.add(chunks)
↓
100 반환
처럼 동작한다.
이 값은 단순한 숫자처럼 보이지만 PDF가 RAG 저장 단위로 몇 개의 Document로 분리되었는지 확인할 수 있는 값이다.
이번 Part 1-2에서 가장 중요하게 이해한 것은 PDF를 RAG에 사용하기 위해서는 문서 변환 과정이 필요하다는 것이다.
특히 다음 세 가지를 구분해서 이해해야 한다.
PagePdfDocumentReader
PDF를 읽어서 Document로 만든다.
TokenTextSplitter
Document를 작은 Chunk로 나눈다.
vectorStore.add(chunks);
나누어진 Chunk를 벡터 저장소에 적재한다.
따라서 이번 프로젝트의 PDF 적재 과정은
Reader → Splitter → VectorStore
라는 구조로 이해하면 된다.
이번에는 PDF를 단순히 파일로 저장하는 것이 아니라 AI가 검색할 수 있는 Document 단위로 변환하는 과정이 필요하다는 것을 배웠다.
특히 PagePdfDocumentReader로 PDF를 읽은 뒤 TokenTextSplitter를 통해 다시 Chunk로 나누는 과정을 직접 코드로 확인하면서, RAG에서 문서 적재가 단순한 파일 업로드와 다르다는 것을 이해할 수 있었다.
또한 Chunk마다 filename metadata를 추가함으로써 본문뿐만 아니라 문서의 출처 정보도 함께 관리할 수 있다는 점도 알게 되었다.
결국 PDF RAG에서는
PDF 파일을 받는 것
에서 끝나는 것이 아니라,
PDF
→ Document
→ Chunk
→ Metadata
→ VectorStore
까지 변환해야 실제 검색에 활용할 수 있다는 것이 이번 Part 1-2의 핵심이었다.