Skip to content

Commit e1bd2d2

Browse files
authored
Merge pull request #107 from DocGrid/feature/103
[Feat] PDF·DOCX 문서 파싱 지원 추가 구현
2 parents 50405aa + a5d53d0 commit e1bd2d2

26 files changed

Lines changed: 1610 additions & 60 deletions

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ dependencies {
3131
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
3232
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
3333
implementation 'io.minio:minio:8.5.17'
34+
implementation 'org.apache.pdfbox:pdfbox:3.0.8'
35+
implementation 'org.apache.poi:poi-ooxml:5.5.1'
3436
implementation 'org.flywaydb:flyway-core'
3537
implementation 'org.flywaydb:flyway-database-postgresql'
3638
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
# #103 PDF·DOCX 문서 파싱 지원 추가 구현
2+
3+
## 1. 배경
4+
5+
현재 문서 인덱싱은 TXT와 Markdown 파일만 지원한다. 업로드 단계는 `txt`, `md` 확장자와 Content-Type만
6+
허용하고, `DocumentParsingService`는 원본 Byte를 `TextDocumentParser`로 엄격하게 UTF-8 Decode한 뒤
7+
`FixedSizeChunker`에 전달한다.
8+
9+
반면 데이터 모델에는 이미 `DocumentType.PDF`, `DocumentType.DOCX`
10+
`document_chunks.page_no`, `section_title`, `metadata_json`이 존재한다. 따라서 PDF·DOCX 지원은 새 DB
11+
구조를 만드는 작업이 아니라, 형식별 파싱 결과를 기존 Chunk 원자 저장 경계에 연결하는 작업이다.
12+
13+
이번 작업은 텍스트가 포함된 PDF와 OOXML DOCX를 지원한다. OCR은 실행하지 않으며, 텍스트를 추출할 수
14+
없는 PDF는 일반 파싱 실패와 구분해 후속 OCR 대상임을 명확히 보고한다.
15+
16+
## 2. 목표
17+
18+
1. PDF·DOCX 업로드를 안전한 확장자·Content-Type 조합으로 허용한다.
19+
2. 문서 형식별 Parser 선택을 명시적인 계약으로 분리한다.
20+
3. PDF Text를 페이지별로 추출하고 Chunk에 1부터 시작하는 Page Number를 저장한다.
21+
4. DOCX Heading, 본문 Paragraph와 Table을 원본 순서대로 추출하고 Section Title을 저장한다.
22+
5. TXT·Markdown의 기존 Canonical Text와 Chunk 결과를 변경하지 않는다.
23+
6. 암호화·손상·빈·스캔 문서를 안정적인 오류 코드로 구분한다.
24+
7. 외부 파싱 중 DB Transaction을 유지하지 않고 전체 Chunk Set 원자 저장을 보존한다.
25+
8. 성공한 PDF·DOCX Chunk가 기존 Batch Embedding 단계에 그대로 연결되게 한다.
26+
27+
## 3. 제외 범위
28+
29+
- Tesseract 또는 다른 OCR Engine 실행
30+
- 스캔 이미지 전처리, 회전 보정, 언어 감지
31+
- 구형 Binary Word `.doc`
32+
- HWP, HTML, PPTX, XLSX
33+
- PDF 이미지·도형·주석·양식 필드 추출
34+
- DOCX 내부 이미지 OCR
35+
- Token 기반 Chunking 전환
36+
- Query Embedding, Vector 검색, RAG, MCP 변경
37+
- 공식 OpenSQL 17.8 원격 검증
38+
39+
## 4. 오픈소스 의존성
40+
41+
| 용도 | Library | Version | License | 선택 이유 |
42+
| --- | --- | --- | --- | --- |
43+
| PDF | Apache PDFBox | 3.0.8 | Apache-2.0 | Java 17에서 PDF Unicode Text와 페이지를 직접 추출 가능 |
44+
| DOCX | Apache POI OOXML | 5.5.1 | Apache-2.0 | XWPF로 Paragraph, Heading, Table의 문서 순서를 읽을 수 있음 |
45+
46+
두 Library는 Apache Software Foundation의 공식 Release를 사용한다. 외부 SaaS, API Key, 상용 SDK나
47+
실행 시 다운로드가 필요한 모델을 추가하지 않는다. 대회 Repository가 Open Source로 공개돼도 License
48+
호환성을 설명할 수 있도록 의존성 용도와 Version을 이 문서에 고정한다.
49+
50+
OCR 후속 작업에는 Apache-2.0 License의 Tesseract를 별도 Process 또는 Container Sidecar로 두는 구성이
51+
가능하다. Java Application에 Native OCR Runtime을 직접 포함하지 않고 현재 Parser 선택 경계 뒤에
52+
선택형 Adapter로 연결한다.
53+
54+
## 5. 전체 구조
55+
56+
```mermaid
57+
flowchart LR
58+
upload["Upload Validation"]
59+
prepare["준비 Transaction"]
60+
storage["Object Storage Read"]
61+
registry["Parser Registry"]
62+
text["TXT·MD Parser"]
63+
pdf["PDFBox Parser"]
64+
docx["POI DOCX Parser"]
65+
parsed["ParsedDocument Segments"]
66+
chunker["Segment-aware Chunker"]
67+
complete["완료 Transaction"]
68+
embedding["Batch Embedding"]
69+
70+
upload --> prepare --> storage --> registry
71+
registry --> text --> parsed
72+
registry --> pdf --> parsed
73+
registry --> docx --> parsed
74+
parsed --> chunker --> complete --> embedding
75+
```
76+
77+
DB Transaction 경계는 변경하지 않는다.
78+
79+
```text
80+
짧은 준비 Transaction
81+
→ Transaction 밖 Storage 읽기·형식별 파싱·Chunk 계산
82+
→ 짧은 완료 Transaction
83+
```
84+
85+
## 6. 업로드 계약
86+
87+
`FileValidationService`는 다음 조합만 허용한다.
88+
89+
| Extension | DocumentType | Content-Type |
90+
| --- | --- | --- |
91+
| `txt` | `TXT` | `text/plain` |
92+
| `md` | `MD` | `text/plain`, `text/markdown` |
93+
| `pdf` | `PDF` | `application/pdf` |
94+
| `docx` | `DOCX` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
95+
96+
`application/octet-stream`은 허용하지 않는다. 확장자만 PDF·DOCX인 임의 Binary가 업로드 단계에서 정상
97+
문서로 오인되는 것을 줄이고, 저장된 `content_type`을 파싱 준비 단계에서 다시 검증할 수 있게 한다.
98+
99+
새 버전 업로드는 기존 문서의 `DocumentType`과 같은 형식만 허용하는 현재 계약을 그대로 사용한다.
100+
101+
## 7. 형식별 Parser 계약
102+
103+
### 7.1 DocumentContentParser
104+
105+
형식별 Parser는 다음 두 책임만 가진다.
106+
107+
```java
108+
public interface DocumentContentParser {
109+
Set<DocumentType> supportedTypes();
110+
ParsedDocument parseDocument(byte[] content);
111+
}
112+
```
113+
114+
- `supportedTypes`: Registry Key로 사용하는 문서 형식 집합. 같은 Byte 계약을 공유하는 TXT·MD Parser는 두 형식을 함께 등록
115+
- `parseDocument`: 원본 Byte를 DB나 Storage에 의존하지 않는 불변 파싱 결과로 변환
116+
117+
Parser는 Chunk 크기, Overlap, 영속화와 Worker 소유권을 알지 않는다.
118+
119+
### 7.2 DocumentParserRegistry
120+
121+
Spring이 제공한 Parser 목록을 `DocumentType`별 Map으로 고정한다.
122+
123+
- 같은 형식 Parser가 둘 이상 등록되면 기동 단계에서 실패한다.
124+
- 현재 형식을 지원하는 Parser가 없으면 `UNSUPPORTED_DOCUMENT_TYPE`을 반환한다.
125+
- `DocumentParsingService`는 구체적인 PDFBox·POI Class를 직접 참조하지 않는다.
126+
127+
이 경계는 후속 OCR Adapter를 PDF 기본 Parser와 섞지 않고 별도 정책으로 선택할 수 있게 한다.
128+
129+
## 8. 파싱 결과 계약
130+
131+
### 8.1 ParsedDocument
132+
133+
`ParsedDocument`는 원본 순서가 보존된 `ParsedDocumentSegment`의 불변 목록이다. 목록이 비었거나 모든
134+
Segment가 공백이면 생성하지 않는다.
135+
136+
### 8.2 ParsedDocumentSegment
137+
138+
각 Segment는 다음 값을 가진다.
139+
140+
| Field | 의미 |
141+
| --- | --- |
142+
| `text` | 줄바꿈이 LF로 정규화된 검색 가능 Text |
143+
| `pageNo` | PDF Page Number, 그 밖의 형식은 `null` |
144+
| `sectionTitle` | DOCX 현재 Heading, 그 밖의 형식은 `null` |
145+
| `metadataJson` | 필요한 경우에만 사용하는 최소 형식 Metadata |
146+
147+
Segment는 Page 또는 Section 경계다. Chunker는 Segment를 넘는 Chunk를 만들지 않는다.
148+
149+
TXT·Markdown은 기존 `TextDocumentParser.parse(byte[])` 결과를 단일 Segment로 감싼다. 기존 공개 Method는
150+
단위 테스트와 단건 사용 경로를 위해 유지한다.
151+
152+
## 9. PDF 파싱
153+
154+
### 9.1 실행 순서
155+
156+
1. `Loader.loadPDF(byte[])`로 메모리의 PDF를 연다.
157+
2. Password가 필요하거나 `PDDocument.isEncrypted()`이면 암호화 PDF 오류로 종료한다.
158+
3. `PDFTextStripper`의 시작·끝 Page를 같은 값으로 설정해 페이지별 Text를 추출한다.
159+
4. 각 Page Text의 CRLF·CR을 LF로 바꾸고 앞뒤 빈 공간만 제거한다.
160+
5. 비어 있지 않은 Page를 해당 1-based Page Number의 Segment로 추가한다.
161+
6. Page는 존재하지만 전체 Segment가 비면 OCR 필요 오류로 종료한다.
162+
163+
### 9.2 페이지 경계
164+
165+
Page 1의 마지막 Text와 Page 2의 첫 Text를 같은 Chunk에 넣지 않는다. 이 방식은 Chunk 하나에 Page Number
166+
하나만 저장할 수 있는 현재 Schema와 일치한다.
167+
168+
페이지별로 Overlap을 다시 시작한다. 검색 결과의 출처 Page를 정확히 보존하는 것을 페이지를 가로지르는
169+
긴 Context보다 우선한다.
170+
171+
### 9.3 오류
172+
173+
- `InvalidPasswordException`, Encryption 확인: `DOCUMENT_PDF_ENCRYPTED`
174+
- Page는 있지만 추출 Text 없음: `DOCUMENT_OCR_REQUIRED`
175+
- 잘못된 PDF Header, 손상된 Object, I/O 오류: `DOCUMENT_PARSING_FAILED`
176+
177+
오류 Log와 API 응답에 PDF Byte, 추출 Text와 Object Storage 경로를 포함하지 않는다.
178+
179+
## 10. DOCX 파싱
180+
181+
### 10.1 문서 순서
182+
183+
`XWPFDocument.getBodyElements()`를 순회해 Paragraph와 Table의 실제 Body 순서를 보존한다.
184+
185+
- Paragraph: `XWPFParagraph.getText()`
186+
- Heading: Style ID가 `Heading`으로 시작하거나 `Title`인 Paragraph
187+
- Table: Row는 LF, Cell은 Tab으로 결합
188+
189+
Header, Footer, Footnote와 Comment는 이번 범위에서 제외한다.
190+
191+
### 10.2 Section 구성
192+
193+
Heading을 만나면 이전 Section을 종료하고 새 Segment를 시작한다. Heading Text 자체도 새 Segment의 첫
194+
줄에 포함한다. 다음 Heading 전까지 본문 Paragraph와 Table Text를 LF로 이어 붙인다.
195+
196+
첫 Heading 이전의 본문은 `sectionTitle = null`인 Segment로 유지한다. 같은 Section의 Table은 본문과
197+
같은 Segment에 원래 순서대로 포함한다.
198+
199+
### 10.3 오류
200+
201+
- 검색 가능한 Paragraph·Table Text 없음: `DOCUMENT_CONTENT_EMPTY`
202+
- 손상된 ZIP, OOXML Package, I/O 오류: `DOCUMENT_PARSING_FAILED`
203+
204+
Macro 포함 `.docm`과 Binary `.doc`은 업로드 단계에서 허용하지 않는다.
205+
206+
## 11. Segment 기반 Chunking
207+
208+
`FixedSizeChunker`는 기존 `chunk(String)`을 유지하고 `chunk(ParsedDocument)`를 추가한다. 문자열 Method는
209+
단일 Segment Parsed Document로 위임해 TXT·Markdown의 결과를 유지한다.
210+
211+
각 Segment의 Unicode Code Point 배열에 기존 Chunk Size와 Overlap을 적용한다.
212+
213+
```text
214+
Segment 1 local [0, 1000) → global [0, 1000)
215+
Segment 2 local [0, 1000) → global [segmentStart, segmentStart + 1000)
216+
```
217+
218+
전역 Offset은 Segment Text를 LF 하나로 연결한 개념적 Canonical Text를 기준으로 계산한다. Segment 사이
219+
LF 한 Code Point를 Offset에 포함하지만 어떤 Chunk에도 저장하지 않는다.
220+
221+
Draft에는 Segment의 `pageNo`, `sectionTitle`, `metadataJson`을 복사한다. `chunkIndex`는 Segment와 관계없이
222+
문서 전체에서 0부터 연속된다. Hash, Token 추정치, Unicode Surrogate Pair 보호는 기존 로직을 사용한다.
223+
224+
## 12. 원자성과 재개
225+
226+
1. 준비 Transaction이 Job, Attempt, Worker, Claim Token, Lease와 Version을 잠그고 검증한다.
227+
2. 지원 형식과 Content-Type을 확인하고 Storage 위치·DocumentType Snapshot을 만든다.
228+
3. Transaction 밖에서 원본을 읽고 Parser Registry와 Chunker를 실행한다.
229+
4. 파싱 또는 Chunking이 실패하면 완료 Transaction을 호출하지 않아 Chunk Row는 0건이다.
230+
5. 완료 Transaction이 소유권과 Version을 다시 검증한다.
231+
6. 전체 Draft를 `saveAllAndFlush`, `CHUNKED` 상태와 Event로 같은 Transaction에서 Commit한다.
232+
7. 동시 요청이 먼저 완료했다면 기존 Chunk Set을 재생한다.
233+
234+
PDF·DOCX 지원은 이 기존 계약을 변경하지 않는다.
235+
236+
## 13. 오류 계약
237+
238+
새 오류를 추가한다.
239+
240+
| ErrorCode | HTTP | 의미 |
241+
| --- | --- | --- |
242+
| `DOCUMENT_PDF_ENCRYPTED` | 422 | Password 또는 Encryption이 적용된 PDF |
243+
| `DOCUMENT_OCR_REQUIRED` | 422 | Page는 있지만 검색 가능한 Text가 없는 PDF |
244+
| `DOCUMENT_PARSING_FAILED` | 422 | 손상되거나 읽을 수 없는 PDF·DOCX |
245+
246+
기존 오류를 유지한다.
247+
248+
- `UNSUPPORTED_DOCUMENT_TYPE`
249+
- `DOCUMENT_CONTENT_EMPTY`
250+
- `DOCUMENT_TEXT_DECODING_FAILED`
251+
- `DOCUMENT_FILE_REFERENCE_MISSING`
252+
- `DOCUMENT_CHUNKS_INCONSISTENT`
253+
254+
Worker 실패 분류는 암호화·OCR 필요·내용 없음·형식 오류를 재시도 불가능한 문서 내용 오류로 처리한다.
255+
Storage나 일시적인 Provider 장애와 혼동하지 않는다.
256+
257+
## 14. 테스트 설계
258+
259+
### 14.1 업로드
260+
261+
- 정상 PDF·DOCX 확장자와 Content-Type
262+
- 대소문자 확장자 정규화
263+
- PDF 확장자 + DOCX Content-Type 등 불일치
264+
- `.doc`, `.docm`, `application/octet-stream` 거부
265+
266+
### 14.2 Parser 단위 테스트
267+
268+
- PDF 두 Page의 Text와 Page Number
269+
- 한 Page가 비어 있어도 다음 Text Page 보존
270+
- 전체 Text가 없는 PDF의 OCR 필요 오류
271+
- Password 보호 PDF 오류
272+
- 손상된 PDF 파싱 실패
273+
- DOCX Heading, 본문, Table, 다음 Heading의 순서와 Section Title
274+
- Heading 이전 본문
275+
- 빈 DOCX와 손상 DOCX 오류
276+
- TXT·Markdown Canonical Text 회귀
277+
278+
테스트 Fixture는 PDFBox와 POI로 메모리에서 생성한다. Binary Fixture를 Repository에 추가하지 않는다.
279+
280+
### 14.3 Chunk 단위 테스트
281+
282+
- Page·Section 경계를 넘지 않는 Chunk
283+
- 문서 전체 연속 `chunkIndex`
284+
- 전역 Character Offset
285+
- 페이지와 Section Metadata 복사
286+
- Unicode Code Point와 Overlap 기존 회귀
287+
288+
### 14.4 통합 테스트
289+
290+
- PostgreSQL 17에서 PDF·DOCX Chunk Row, Page·Section, 상태와 Event 저장
291+
- 중간 Parser 실패 시 Chunk 0건
292+
- 같은 실행의 순차·동시 재호출 수렴
293+
- Worker Pipeline이 Parsing 이후 기존 Batch Embedding으로 진행
294+
- 전체 Gradle Test
295+
296+
## 15. 보안과 운영
297+
298+
- 업로드 Content-Type만 신뢰하지 않고 실제 Parser가 Binary 구조를 검증한다.
299+
- 압축 해제 Bomb 위험을 줄이기 위해 POI의 OOXML 기본 보호를 유지하며 파일 전체 크기는 기존 업로드 제한을
300+
적용한다.
301+
- 문서 원문, PDF Password 시도, Vector와 Object Storage Key를 Log에 남기지 않는다.
302+
- Parser 오류는 제한된 오류 코드와 고정 메시지만 외부로 반환한다.
303+
- PDFBox·POI Version은 Gradle에 명시해 재현 가능한 Build를 유지한다.
304+
305+
## 16. 커밋 분리
306+
307+
1. `docs: #103 PDF·DOCX 문서 파싱 상세 설계 추가`
308+
2. `feat: #103 문서 형식별 파싱 계약과 업로드 검증 확장`
309+
3. `feat: #103 PDF 페이지별 텍스트 파싱 구현`
310+
4. `feat: #103 DOCX 제목·본문·표 파싱 구현`
311+
5. `feat: #103 Segment 기반 Chunk 메타데이터 연동`
312+
6. `test: #103 PDF·DOCX 파싱 검증 및 결과 기록`
313+
314+
각 구현 Commit은 Compile 또는 직접 영향 단위 테스트가 통과해야 한다.
315+
316+
## 17. 완료 조건
317+
318+
- PDF·DOCX 업로드 조합이 허용된다.
319+
- 텍스트 PDF가 페이지별 Segment와 Page Number를 만든다.
320+
- DOCX Heading·본문·Table이 문서 순서와 Section Title을 보존한다.
321+
- 스캔 PDF, 암호화 PDF, 손상·빈 문서가 안정적인 오류로 구분된다.
322+
- TXT·Markdown 결과가 기존과 동일하다.
323+
- Segment 경계를 넘지 않는 Chunk가 Page·Section과 전역 Offset을 저장한다.
324+
- 파싱 실패 시 Chunk가 부분 저장되지 않는다.
325+
- 성공한 Chunk가 기존 Batch Embedding 파이프라인으로 전달된다.
326+
- PostgreSQL 통합 테스트와 전체 회귀 테스트가 통과한다.
327+
328+
Closes #103

0 commit comments

Comments
 (0)