|
| 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