문서 파싱 서비스 docreader
문서 파싱은 업로드한 파일을 검색 가능한 텍스트로 변환하고 원본 이미지 참조를 추출합니다. WeKnora는 다양한 형식을 지원하며 파일 유형별로 파싱 엔진을 선택할 수 있습니다. 스캔 문서, 이미지, 오디오에는 각각 해당하는 비전 또는 음성 모델도 필요합니다.
지원 형식:
| 분류 | 형식 |
|---|---|
| 문서 | PDF, Word(doc/docx), PPT(ppt/pptx), Excel(xls/xlsx), EPUB, XMind |
| 텍스트 | txt, Markdown, CSV, JSON |
| 웹페이지 | 온라인 URL 수집, 로컬 HTML / MHTML 아카이브 |
| 이미지 | jpg, png, gif, bmp, tiff, webp(내용을 이해하려면 비전 모델 설정 필요) |
| 오디오 | mp3, wav, m4a, flac, ogg(음성 인식 모델 설정 필요) |
파싱 결과가 만족스럽지 않다면 다음을 조정할 수 있습니다.
- PDF 레이아웃 복원이 부정확하거나 표가 어긋나는 경우: 지식 베이스의 파싱 설정에서
pdf에 다른 파싱 엔진(MarkItDown / OpenDataLoader / MinerU)을 지정합니다. - 스캔 문서에서 텍스트를 인식하지 못하는 경우: 비전 모델 설정을 확인하고, 필요하면 스캔 문서 모드를 강제로 사용합니다.
- Excel의 첫 행이 열 이름인데 데이터로 처리되는 경우:
xlsx/xls에 ‘첫 행을 헤더로 사용’을 켭니다. - 청크 내용을 수정해야 하는 경우: 청크 목록에서 본문을 편집합니다. 지식 베이스와 지식 관리를 참고하세요.
파싱 메커니즘과 설정 참고
docreader는 파일이나 URL을 Markdown과 원본 이미지 참조로 변환하는 독립적인 Python gRPC 서비스입니다. 이후 Go 메인 서비스가 청크 분할, 이미지 저장, OCR, 이미지 설명, 벡터화를 수행합니다.
파싱 서비스와 후속 처리의 책임은 docreader/parser/base_parser.py에 정의되어 있습니다.
class BaseParser(ABC):
"""기본 파서 인터페이스.
경량화 리팩터링 이후 BaseParser는 문서에서 Markdown 텍스트와
원본 이미지 참조만 추출합니다. 청크 분할, 이미지 저장, OCR,
VLM 캡션은 Go App 모듈에서 처리합니다.
"""서비스 역할과 외부 인터페이스
인터페이스 프로토콜: 순수 gRPC(HTTP 없음)
서비스 진입점은 docreader/main.py입니다. gRPC server(grpc.server + ThreadPoolExecutor) 하나만 시작하고 기본적으로 50051 포트에서 수신합니다. 표준 gRPC Health 서비스(grpc_health.v1)도 등록하여 K8s / Docker 상태 확인에 사용합니다(이미지 안의 grpc_health_probe 바이너리와 함께 사용). HTTP 인터페이스는 전혀 없습니다.
Proto는 docreader/proto/docreader.proto에 정의되어 있으며 RPC는 총 3개입니다.
service DocReader {
rpc Read(ReadRequest) returns (ReadResponse) {}
// 스트리밍 버전: meta(markdown/metadata/error) 프레임 1개를 먼저 보내고, 이후 프레임마다 이미지 1개를 보냅니다.
// 대형 스캔 PDF(수백 페이지 이미지)가 unary 메시지 크기 제한(RESOURCE_EXHAUSTED)에 걸리지 않도록 합니다.
rpc ReadStream(ReadRequest) returns (stream ReadStreamResponse) {}
rpc ListEngines(ListEnginesRequest) returns (ListEnginesResponse) {}
}ReadRequest는 통합 요청입니다. file_content/file_name/file_type을 설정하면 파일 모드, url/title을 설정하면 URL 모드입니다. config.parser_engine으로 엔진(builtin / markitdown / opendataloader)을 지정하고, config.parser_engine_overrides로 엔진별 재정의 매개변수(예: pdf_force_scanned, odl_hybrid)를 전달합니다.
ReadResponse는 markdown_content + repeated ImageRef image_refs를 반환합니다. 이미지는 inline bytes로 응답에 직접 포함되며, image_dir_path는 항상 빈 문자열입니다. 이미지 영속화는 전적으로 Go App이 담당하고, proto의 기존 필드 3 image_storage는 reserved로 지정되었습니다.
ReadStream의 장점(main.py::ReadStream과 _iter_image_refs 참고): 각 프레임이 독립적이고 작습니다. 서버는 base64를 디코딩하면서 images.pop(ref_path)로 원본 데이터를 해제하므로 양쪽 모두 전체 이미지를 동시에 보유할 필요가 없습니다. 대형 스캔 PDF의 최대 메모리 사용량과 메시지 크기 문제를 해결합니다. Go의 internal/infrastructure/docparser/grpc_parser.go는 ReadStream을 우선 호출하고, 구버전 docreader가 Unimplemented를 반환하면 unary Read로 자동 폴백합니다.
ListEngines는 하위 호환성을 위해 유지됩니다. 주석에 명시된 대로 엔진 목록은 이제 Go의 internal/infrastructure/docparser/engine_registry.go(docparser.ListAllEngines)에서 관리합니다. Go App은 더 이상 이 RPC를 호출하지 않으며 MinerU 같은 원격 엔진은 Go에서 직접 처리합니다.
인증과 TLS(auth.py)
docreader/auth.py는 환경 변수로 활성화하는 두 가지 보안 계층을 제공합니다.
Token 인증(AuthInterceptor): GRPC_AUTH_TOKEN을 설정하면 활성화됩니다. 클라이언트는 metadata에 authorization: Bearer <token>(또는 token만)을 포함해야 합니다. 검증에는 타이밍 공격 방지를 위해 hmac.compare_digest를 사용하며, token이 16바이트보다 짧으면 경고를 기록합니다. 두 상태 확인 메서드(/grpc.health.v1.Health/Check, /Watch)는 인증 전에 통과시켜 상태 확인에 영향을 주지 않습니다. 인증 실패 시 _make_abort_handler로 원래 RPC 종류(unary/stream)에 맞는 abort handler를 구성하여, 프레임워크가 INTERNAL을 발생시키는 대신 UNAUTHENTICATED를 반환합니다.
TLS / mTLS(load_tls_credentials): GRPC_TLS_ENABLED=true이면 GRPC_TLS_CERT / GRPC_TLS_KEY가 필수이며 GRPC_TLS_CA는 선택 사항입니다. GRPC_MTLS_REQUIRE_CLIENT_CERT=true는 클라이언트 인증서를 강제합니다(설정하지 않으면 GRPC_TLS_CA 존재 여부로 자동 판단). TLS 설정이 누락되거나 로드에 실패하면 항상 TLSConfigError를 발생시키고, main()에서 이를 잡아 sys.exit(1)로 즉시 종료하며 평문으로 조용히 다운그레이드하지 않습니다.
Go 클라이언트는 docreader/client/auth.go에 있습니다. LoadAuthConfigFromEnv가 같은 이름의 환경 변수 GRPC_TLS_ENABLED/CERT/KEY/CA/SERVER_NAME과 GRPC_AUTH_TOKEN을 읽으며, docreader/client/client.go의 NewClient는 round_robin 부하 분산과 MAX_FILE_SIZE_MB 메시지 제한이 적용된 연결을 구성합니다.
메인 서비스와의 상호 작용 순서
Go App의 internal/application/service/knowledge_process.go는 문서 수집 파이프라인의 docreader stage에서 파싱을 호출합니다. 타임아웃은 docreader_call_timeout으로 제어하여 멈춘 docreader가 worker를 장시간 점유하지 않도록 합니다. 주의: md/markdown/txt/csv/json/이미지/오디오는 Go의 SimpleFormatReader가 직접 처리하며 docreader를 거치지 않습니다(internal/infrastructure/docparser/builtin_converter.go의 simpleFormats 참고).
파서 등록과 디스패치 메커니즘
엔진 레지스트리(parser/registry.py)
ParserEngineRegistry는 엔진 이름 → {파일 확장자 → 파서 클래스}의 2단계 매핑을 관리합니다. 엔진별 check_available 프로브도 등록할 수 있습니다(ListEngines에서 가용성과 사용할 수 없는 이유를 보고하는 데 사용).
_build_default_registry()는 세 엔진을 등록합니다.
| 엔진 | 파일 유형 | 설명 |
|---|---|---|
builtin | docx(Docx2Parser), doc(DocParser), pdf(PDFParser), md/markdown(MarkdownParser), xlsx/xls(ExcelParser), epub(EPUBParser), html/htm(HTMLParser), mhtml(MHTMLParser), jpg/jpeg/png/gif/bmp/tiff/webp(ImageParser) | 내장 파싱 엔진 |
markitdown | md, markdown, pdf, docx, doc, pptx, ppt, xlsx, xls, csv(모두 MarkitdownParser) | Microsoft MarkItDown 라이브러리. PPT/PPTX는 이 엔진만 지원 |
opendataloader | pdf(OpenDataLoaderParser) | OpenDataLoader PDF 레이아웃 분석. Java 11+ 필요. check_available이 java, Python 패키지, hybrid 서비스 상태 확인 |
디스패치 규칙(get_parser_class): 요청에서 지정한 엔진이 해당 파일 유형을 지원하지 않으면 builtin 엔진으로 자동 폴백합니다. builtin에도 없으면 ValueError("Unsupported file type")을 발생시킵니다.
파사드와 파일 매직 넘버 보정(parser/parser.py)
Parser는 파사드 클래스입니다. parse_file()은 레지스트리를 사용하고 parse_url()은 항상 WebParser를 사용합니다. 중요한 방어 장치 중 하나가 detect_effective_file_type()입니다. OOXML .docx는 실제로 ZIP 컨테이너이고 구형 .doc는 OLE Compound File입니다. WPS/Word는 .doc를 .docx로 바꾼 파일명도 허용하므로, OLE 매직 넘버(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1")로 시작하는 "docx"는 DOC 파서로 강제 라우팅합니다. 바이너리 OLE 데이터를 DOCX 파서에 전달하지 않기 위한 조치입니다.
엔진 재정의 매개변수 engine_overrides(proto의 parser_engine_overrides에서 전달)는 **kwargs로 파서 생성자에 전달합니다. 예를 들어 pdf_force_scanned는 PDFParser.__init__에서 받습니다.
체인형 파서(parser/chain_parser.py)
두 가지 ‘책임 연쇄’ 조합기 모두 클래스 팩터리 create(*parser_classes)로 하위 클래스를 동적으로 생성합니다.
FirstParser: 여러 파서를 순서대로 시도하고document.is_valid()(즉content != "")를 만족하는 첫 결과를 반환합니다. 예외를 잡은 뒤에는 다음 파서를 계속 시도합니다. 대표적인 사용 예:Docx2Parser = FirstParser.create(MarkitdownParser, DocxParser).PipelineParser: 각 파서의 출력 텍스트를 bytes로 다시 인코딩해 다음 파서의 입력으로 전달하는 파이프라인입니다. 각 단계의images/metadata는 누적 병합합니다. 대표적인 사용 예:MarkdownParser = PipelineParser.create(MarkdownTableFormatter, MarkdownImageBase64),WebParser = PipelineParser.create(StdWebParser, MarkdownParser),MarkitdownParser = PipelineParser.create(StdMarkitdownParser, MarkdownParser).
동시성 모델(parser/concurrency.py 및 관련 코드)
동시성 제어는 네 계층으로 나뉩니다.
- gRPC 스레드 풀:
ThreadPoolExecutor(max_workers=CONFIG.grpc_max_workers)(기본값 4), 즉 동시에 최대 4개 요청을 처리합니다. - 이름 기반 세마포어 제한(
parser_worker_limit(name, max_workers)): 프로세스 수준threading.BoundedSemaphore를 이름별로 재사용하여 무거운 백엔드의 동시 실행을 제한합니다. 현재 제한 지점은"markitdown"(기본값 1),"opendataloader"(기본값 1, convert마다 JVM 시작),"pdf_render"(기본값 1)입니다.max_workers <= 0이면 제한하지 않습니다. - pdfium 전역 잠금(
pdf_parser.py::_PDFIUM_LOCK): pdfium C 라이브러리는 프로세스 전역 상태를 사용하며 스레드 안전하지 않습니다. 두 gRPC worker가 동시에 PDF를 파싱하면 공유 상태가 손상되거나 전체 프로세스가 교착 상태에 빠질 수 있습니다(요청이 "Parsing document with PDFParser"에서 영원히 멈추는 사례가 관측됨). 따라서 모든 pdfium 작업(텍스트 추출, 페이지 렌더링, 이미지 추출)을 이 전역 잠금으로 직렬화하며, 동시 PDF 업로드는 대기열에서 처리합니다. PDF 이외의 파서에는 영향이 없습니다. - 프로세스 수준 병렬 처리:
- PDF 스캔 페이지 렌더링:
_render_pages_parallel은ProcessPoolExecutor를 사용합니다(멀티스레드 프로세스의 fork 위험을 피하려고forkserver시작 방식을 우선 사용). 단일 PDF의 스캔 페이지를 나누어 여러 worker 프로세스에 렌더링을 맡깁니다. 각 프로세스는 임시 파일에서PdfDocument를 독립적으로 엽니다. 병렬도는DOCREADER_PDF_RENDER_PARALLELISM(기본값min(4, cpu))으로 설정합니다. CPU가 제한된 컨테이너에서 ‘대형 스캔 문서 렌더링에 1시간 이상’ 걸리던 문제를 줄이는 핵심 수단이며, 실패하면 투명하게 직렬 처리로 폴백합니다. - DOCX 페이지별 병렬 처리:
docx_parser.py::Docx가 페이지 작업을ProcessPoolExecutor+Manager공유 리스트에 분배합니다. 이미지는/tmp/docx_img_*임시 파일로 프로세스 간 전달하고, 마지막에 메인 프로세스에서 한꺼번에 인코딩하여 업로드합니다. - LibreOffice 변환(doc→docx, ppt→pptx, xls→xlsx):
subprocess로soffice --headless를 호출합니다. 매 시도마다 별도-env:UserInstallation=<임시 profile>을 사용해 동시 soffice가 사용자 profile 잠금을 놓고 경쟁하여 조용히 실패하는 문제를 피합니다. 실패 시 백오프하며 3회 재시도합니다.
- PDF 스캔 페이지 렌더링:
파서별 상세 설명
pdf_parser.py — PDFParser / PDFScannedParser(builtin 엔진의 PDF)
의존성: pypdfium2(+ Pillow). 외부 서비스(MinerU / Docling 등)는 필요하지 않으며 docreader 자체는 OCR을 수행하지 않습니다.
핵심 설계: 페이지별 라우팅(per-page routing). 각 페이지를 독립적으로 "text" 또는 "scanned"로 분류합니다(_classify_page). 주 신호는 이미지 면적 비율(페이지 image 객체의 경계 상자 면적 / 페이지 면적, 임계값 DOCREADER_PDF_SCAN_IMAGE_RATIO=0.5)입니다. 스캔 페이지는 본질적으로 페이지 전체를 덮는 큰 이미지 한 장이며, 내장 OCR 텍스트 레이어가 있더라도 대개 품질이 낮습니다. 보조 신호는 텍스트 레이어의 문자 수가 DOCREADER_PDF_SCAN_MIN_CHARS(10)보다 적고 일정량의 이미지 내용이 있는지 여부입니다. 이 설계는 MinerU / Docling / DeepDoc의 라우팅 방식과 일치하며, 저품질 텍스트 레이어를 신뢰해 RAG 내용이 깨지는 일을 방지합니다.
처리 흐름(_route_locked, 세 가지 Pass):
- Pass 1 텍스트 추출 + 분류: text 페이지는 텍스트 레이어를 사용합니다.
DOCREADER_PDF_LAYOUT_ORDERING=true(기본값)이고 pdfium 일반 텍스트의 구조가 올바르지 않으면(_plain_is_well_formed) 기하 기반 레이아웃 재구성을 수행합니다. glyph 단위 추출(숨겨진 텍스트 render-mode 3과 페이지 밖 글리프를 걸러 숨겨진 텍스트 prompt injection 방지), XY-cut 재귀 열 분할(다단을 열 순서로 선형화), 사이드바/세로 워터마크 열 제거(arXiv 사이드바), 글자 간격으로 단어 사이 공백 추론(WORD_GAP_WIDTH_RATIO), 페이지 중앙값 대비 행 높이로 큰 글꼴 행을 Markdown 제목으로 승격(DETECT_HEADINGS)합니다. 재구성 결과가 조각난 것으로 보이면(_should_prefer_plain의 여러 휴리스틱) 일반 텍스트로 폴백합니다. 이어서_postprocess_pdf_text가 U+FFFE 같은 자리표시 문자, arXiv 워터마크 행, 페이지 번호 행, 벡터 차트에서 텍스트 레이어로 유출된 축/범례 조각(STRIP_CHART_TEXT_DEBRIS)을 정리합니다. text 페이지에서Figure Ncaption을 감지하면 caption 위의 벡터 그림 영역을 JPEG로 렌더링(RENDER_VECTOR_FIGURES)하고형태로 caption 앞에 삽입합니다. - Pass 2 스캔 페이지 렌더링: scanned 페이지만 JPEG로 렌더링합니다(DPI
DOCREADER_PDF_RENDER_DPI=200, 품질DOCREADER_PDF_JPEG_QUALITY=85, 긴 변 제한DOCREADER_PDF_RENDER_MAX_EDGE=2000px. 초대형 페이지 경계를 선언한 PDF가 100+ MP 이미지로 렌더링되어 gRPC 제한에 걸리는 것을 방지). markdown에는자리표시자를 넣고 metadata에image_source_type=scanned_pdf를 표시합니다. 이 페이지 이미지의 OCR은 Go App이 수행합니다(Go의image_multimodal.go는scanned_pdf출처에 전용ocr_prompt사용). - Pass 3 내장 이미지 추출: text 페이지에서 내장 삽화/차트를 추출합니다(
EXTRACT_EMBEDDED_IMAGES). 최소 픽셀(80), 최소 페이지 면적 비율(1%), 페이지 간 반복률(동일 MD5가 text 페이지의 ≥50%에 나타나면 로고/워터마크로 보고 제거), 문서당 상한(50장)으로 필터링하고, 페이지 안에서 위에서 아래 순서로 markdown에 삽입합니다.
별도로 _strip_repeating_lines는 페이지마다 반복되는 머리글과 바닥글을 보수적으로 제거합니다(후보는 각 페이지의 첫 행과 마지막 행으로 한정하며, 짧고 text 페이지의 ≥60%에 나타나야 함).
예외가 발생하면 항상 **PDFScannedParser**로 폴백합니다. 모든 페이지를 JPEG로 렌더링하는 최종 대체 파서이며, pdf_force_scanned 강제 스캔 모드에서도 사용합니다. 업로드별 override 또는 DOCREADER_PDF_FORCE_SCANNED로 켤 수 있습니다.
출력: Markdown(text 페이지 텍스트 + 이미지 자리표시자), images dict, metadata(page_count/scanned_page_count/text_page_count/embedded_image_count/vector_figure_count/image_source_type).
한계: 표 구조를 인식하지 않습니다(텍스트 레이어의 표를 행 단위로 출력). 제목 인식은 글꼴 크기 휴리스틱을 사용합니다. 스캔 페이지 텍스트는 Go 측 OCR에 전적으로 의존합니다.
doc_parser.py — DocParser(.doc 구형 Word)
Docx2Parser를 상속하며 처리 체인은 다음 순서대로 시도합니다.
_parse_with_docx: LibreOffice(soffice --headless --convert-to docx, 독립 profile + 3회 재시도)로 DOC를 DOCX로 변환한 후 부모 클래스의 DOCX 체인으로 파싱합니다(이미지를 추출할 수 있는 유일한 경로)._parse_with_antiword:antiword명령줄로 일반 텍스트를 추출합니다(SandboxExecutor로 실행하며 프록시 환경 변수를 강제 주입합니다. 기본http://128.0.0.1:1‘블랙홀 프록시’로 자식 프로세스의 의도치 않은 외부 연결을 차단)._parse_with_textract: 비활성화됨(textract에 SSRF 취약점이 있어 코드는 남겨 두되 주석 처리).
의존성: LibreOffice(soffice), antiword(이미지에 설치됨). 탐색 경로는 LIBREOFFICE_PATH/ANTIWORD_PATH 환경 변수를 지원합니다. 한계: LibreOffice가 없으면 antiword 일반 텍스트로 축소됩니다(이미지와 표 구조 없음).
docx2_parser.py와 docx_parser.py의 차이
- **
Docx2Parser(레지스트리에서 docx의 실제 진입점)**의 핵심 코드는 3줄뿐입니다.FirstParser.create(MarkitdownParser, DocxParser)— MarkItDown을 먼저 시도하고(빠르며 표의 Markdown 변환 품질이 좋음), 실패하거나 내용이 비어 있으면 자체 개발한DocxParser로 폴백합니다. - **
DocxParser(docx_parser.py, 1500+줄)**는 자체 개발한 python-docx 파서입니다.- python-docx 패치
load_from_xml_v2를 적용했습니다(target_ref가../NULL인 손상된 관계를 건너뜀. python-docx issue #1105에서 유래). Docx처리 클래스가 페이지 나눔(lastRenderedPageBreak/w:br type="page"/sectPr)을 인식하며, 문단이 >1000개인 대형 문서는 ‘페이지당 약 25문단’ 휴리스틱 매핑을 사용합니다. 페이지별로 여러 프로세스에서 병렬 처리합니다.- 문단별 텍스트 + 내장 이미지를 추출합니다(
a:blip/@r:embed→ related_part blob → PIL, <50px 장식 이미지는 건너뛰고 >1920px는 축소). 텍스트/이미지의 원래 순서(content_sequence)를 유지하며, 이미지는_inline_upload콜백을 거쳐 base64로images/<uuid>.<ext>에 인라인 포함합니다. - 표를 HTML
<table>로 변환합니다(동일 텍스트가 있는 인접 셀은 colspan으로 병합). - 전체 실패 시
_parse_using_simple_method로 폴백합니다(순수 python-docx로 문단 + 표 행을 순서대로 추출, 이미지 없음). - 페이지 수 상한은
DOCREADER_DOCX_MAX_PAGES입니다(기본값 0 = 제한 없음).
- python-docx 패치
excel_parser.py와 세 보조 모듈(.xlsx / .xls)
**ExcelParser**는 pandas 기반입니다. sheet별 DataFrame을 읽고 완전히 빈 행을 제거한 뒤, 각 행을 열 이름: 값,열 이름: 값 형태의 키-값 텍스트로 변환합니다. 행마다 Chunk 하나를 생성하며 start/end 위치를 포함합니다. WPS =DISPIMG("ID",mode)와 Office 365 =_xlfn.IMAGE(...) 같은 내장 이미지 함수 문자열은 제거합니다(_IMAGE_FUNC_RE). 이미지는 추출하지 않습니다.
헤더 모드: XLSX와 구형 XLS의 동작은 통일되어 있습니다. 기본적으로 첫 행을 데이터로 처리하고 열 이름에는 A/B/C 문자를 사용합니다(데이터 행을 헤더로 잘못 판단해 버리지 않도록 방지). 실제로 첫 행이 열 이름인 평면 표라면 파싱 엔진 규칙에서 xlsx_first_row_as_header를 켤 수 있습니다. 이때 첫 행이 열 레이블로 승격되고 키-값 텍스트는 이름: 장삼,부서: 연구개발처럼 의미를 가진 형태가 됩니다. 빈 셀이나 이미지 함수 값은 열 문자로 대체하고, 중복 레이블에는 _2, _3 접미사를 자동으로 붙여 고유성을 보장합니다.
이 스위치는 KB의 parser_engine_rules[].xlsx_first_row_as_header로 설정합니다(업로드 확인 대화상자에서 해당 업로드에만 재정의할 수도 있음). 백엔드 applyParserRuleOverrides()는 파일 유형이 xlsx/xls이고 엔진이 builtin(또는 빈 값)인 규칙에만 적용하며, 최종적으로 parser_engine_overrides로 docreader에 전달합니다. 필드 유형은 *bool입니다. null은 파서 기본값 사용, 명시적 false는 끄기를 뜻합니다.
세 보조 모듈은 실제 환경의 비정상 파일을 처리합니다.
excel_convert.py: 매직 넘버/inspect_excel_format으로 실제 형식(xlsx/xls/xlsb/ods)을 감지하고 형식별 pandas engine(xlrd/openpyxl/odf)을 선택합니다. 식별할 수 없으면(예: WPS.et, 이름이 바뀐 csv) LibreOfficeconvert-to xlsx로 정규화합니다(normalize_excel_bytes가.xlsx/.xls/.et/.csv접미사를 차례로 시도).xlsx_merge.py:fill_merged_cells_xlsx가 병합 셀을 해제하고 왼쪽 위의 대표 값을 병합 영역의 모든 셀에 복사합니다. openpyxl은 왼쪽 위에만 값을 저장하고 pandas는 나머지를 NaN으로 읽으므로, 값을 채워야 행별 RAG 청크가 문맥을 유지할 수 있습니다.xlsx_repair.py: 흔한 XLSX 패키징 문제를 복구합니다.sharedStrings.xml경로의 대소문자/위치가 비표준이면 이름을 바꿔 올바른 위치로 옮깁니다. manifest가 sharedStrings를 참조하지만 패키지에는 없고 워크시트가 inline string만 사용하면[Content_Types].xml과 workbook rels에서 참조를 제거하여 openpyxl이 읽을 수 있게 합니다.
XLSX를 읽기 전에 모두 repair → fill_merged_cells 전처리를 거치며, header=None + A/B/C 열 문자를 안정적인 열 이름으로 사용합니다(xls는 먼저 첫 행을 헤더로 시도하고 Unnamed: 열을 만나면 열 문자로 폴백).
ppt_convert.py / pptx_media.py(.ppt / .pptx, markitdown 엔진용)
PPT 계열에는 독립 파서가 없으며 MarkitdownParser가 처리합니다. 이 두 모듈은 전처리/후처리를 돕습니다.
ppt_convert.py:normalize_ppt_bytes가 매직 넘버로 판단합니다(ZIP=pptx는 그대로 통과, OLE=구형 ppt는 LibreOfficeconvert-to pptx, 독립 profile + 3회 재시도). LibreOffice가 없으면 .ppt에 대해 즉시 오류를 발생시키고 설치를 안내합니다.pptx_media.py: MarkItDown이 인라인 처리하지 못하는 PPTX 미디어(특히 WMF/EMF/SVG 벡터 이미지)를 보완합니다.ppt/media/의 모든 리소스를 풀고 Pillow(비트맵) 또는 ImageMagickconvert(벡터 및 모든 형식의 최종 대안)로 차례로 PNG 래스터화합니다. 이어서 markdown의 미해결참조를 순서대로images/<uuid>.png로 바꾸고 이미지 데이터를 인라인 포함합니다.
image_parser.py — ImageParser(독립 이미지 파일)
가장 단순한 파서(29줄)이며 OCR은 전혀 수행하지 않습니다. 전체 이미지를 base64로 Document.images에 인라인 포함하며 본문은  한 줄뿐입니다. OCR 엔진은 Go 측에 있습니다. docreader의 Dockerfile 주석은 ‘OCR/PaddleOCR 관련 의존성 제거’를 명시합니다. Go에서 internal/infrastructure/docparser/paddleocr_vl_converter.go / paddleocr_vl_cloud_converter.go(PaddleOCR-VL)와 image_multimodal.go로 OCR 및 caption을 처리합니다. 또한 Go의 simpleFormats가 이미지 형식을 이미 Go 직접 처리 대상으로 포함하므로, docreader의 ImageParser는 주로 gRPC를 직접 호출하는 SDK 시나리오에 사용됩니다.
markdown_parser.py — MarkdownParser(.md / .markdown)
PipelineParser.create(MarkdownTableFormatter, MarkdownImageBase64):
MarkdownTableFormatter: 인코딩 자동 감지(endecode.decode_bytes: utf-8 → gb18030 → gb2312 → gbk → big5 → ascii → latin-1) 후 표를 정규화합니다.| cell |간격과 정렬 표시를 통일하고,normalize_spurious_table_prefixes가 MarkItDown이 생성한 잘못된 빈 행/구분 행 접두사를 수정하며, 헤더가 없는 Word 표에| --- |GFM 구분 행을 추가합니다.MarkdownImageBase64:내장 이미지를images/<uuid>.<ext>참조 +Document.images데이터로 추출합니다(MIME 하위 유형은x-emf처럼 하이픈이 있는 형식도 지원).
이 파서는 MarkitdownParser / WebParser 파이프라인의 공통 후처리 단계이기도 합니다.
web_parser.py — WebParser(URL 모드)
PipelineParser.create(StdWebParser, MarkdownParser). StdWebParser는 **Playwright(WebKit 엔진)**로 페이지를 렌더링하고 trafilatura로 본문을 추출해 Markdown으로 변환합니다.
- SSRF 이중 방어: 탐색 전에
is_ssrf_safe_url(url)로 검증하고,page.route("**/*")로 라우트 가드를 설치해 모든 하위 요청과 리디렉션 대상을 동일하게 검증합니다(utils/ssrf.py는 Go의internal/utils/security.go정책을 반영합니다. 내부망/루프백/link-local/클라우드 metadata 도메인,.local/.internal등의 접미사, 직접 IP, IP 형태 호스트명, DNS가 반환한 제한 IP, 위험 포트를 모두 차단하며SSRF_WHITELIST/SSRF_WHITELIST_EXTRA환경 변수로 허용). - SPA 지원:
domcontentloaded후 networkidle(10s)을 기다리고#app/main/body의 표시 텍스트가 ≥80자가 될 때까지 기다려(15s) JS 렌더링 페이지에 대응합니다. - WeChat 공식 계정 대응: trafilatura 내부
utils.IMAGE_EXTENSION(mmbiz.qpic.cn/...wx_fmt=형태의 확장자 없는 이미지 인식)과xpaths.BODY_XPATH(#js_content/.rich_media_content우선)에 monkey-patch를 적용합니다. - 폴백: trafilatura가 본문을 추출하지 못하면 Playwright의 표시 텍스트(≥50자) + 페이지 title을 최종 대안으로 사용합니다.
- 프록시는
DOCREADER_EXTERNAL_HTTPS_PROXY를 사용합니다. metadata에서title을 추출합니다.
mhtml_parser.py — MHTMLParser(.mhtml 웹페이지 아카이브)
표준 라이브러리 email로 MIME 구조를 파싱합니다. 모든 text/html part를 수집하고 광고가 아닌 가장 큰 part를 본문으로 선택합니다(googleads/doubleclick 등의 도메인 차단 목록으로 필터링). image/* part는 images/...로 추출하고(Content-Location 파일명을 우선 사용, 충돌 시 _2 접미사 추가), Content-Location/Content-ID(cid:)/X-Attachment-Id의 여러 표기(HTML 이스케이프, URL 인코딩, basename, 상대 경로 urljoin)로 별칭 표를 만들어 <img src>를 다시 씁니다. HTML → Markdown에는 BeautifulSoup(script/style/noscript/iframe 제거, 사이트 내부 링크 unwrap) + markdownify를 사용하고, 코드 펜스를 고려하여 빈 행을 정규화합니다. 모두 실패하면 ```html 코드 블록으로 대체합니다. metadata: source_format=mhtml, file_size, image_count.
html_parser.py — HTMLParser(.html / .htm 정적 웹페이지 파일)
사용자가 직접 업로드한 HTML 파일은 이 경로를 사용하며 parse_url()의 온라인 수집과 구분됩니다. HTMLParser = PipelineParser.create(HTMLToMarkdownParser, MarkdownParser).
HTMLToMarkdownParser는 먼저BeautifulSoup(content, "lxml")로 원본 바이트를 디코딩합니다. BOM과 HTML 안의 charset 선언을 먼저 확인한 후 공통 Markdown 변환을 수행합니다.- HTML → Markdown은
MHTMLParser.html_to_markdown()을 재사용하되,extract_images=False(로컬 HTML에는 추출할 MIME 첨부 파일 없음),strip_internal_links=False(사이트 내부 링크 유지),fallback_to_raw_html=False(내용을 변환하지 못하면 전체```html블록을 넣는 대신 빈 값 반환)를 전달합니다. - 본문의
<img src="http://...">로 참조하는 원격 이미지는 Go에서 보완합니다.internal/infrastructure/docparser/image_resolver.go가 SSRF 검증을 거쳐 원격 이미지를 내려받고 객체 스토리지에 저장한 뒤 참조를 다시 씁니다. 따라서 로컬 업로드 이미지와 같은 OCR / caption 흐름을 사용합니다.
epub_parser.py — EPUBParser(.epub 전자책)
주 경로는 ebooklib를 사용합니다(임시 파일을 통해 읽기). DC 메타데이터(title/author/publisher/language/description/date/isbn)를 추출하고, TOC 순서를 우선하여 장별로 처리합니다(각 장의 첫 h1/h2를 장 제목으로 사용하며 ## 장 제목 + markdownify로 변환한 본문 출력). ITEM_IMAGE는 모두 images/<uuid>.<ext>로 추출하고 여러 경로 변형 별칭을 이용해 <img src>를 다시 씁니다. EPUB 내부 링크(장 간 이동, #fragment)는 unwrap하여 텍스트만 남깁니다. ebooklib 실패 시 ZIP 직접 읽기로 폴백합니다. html/xhtml 파일을 chapter(\d+) 순서로 정렬하여 하나씩 변환합니다. metadata에는 chapter_count/image_count가 포함됩니다.
markitdown_parser.py — MarkitdownParser(markitdown 엔진)
PipelineParser.create(StdMarkitdownParser, MarkdownParser). StdMarkitdownParser는 Microsoft MarkItDown 라이브러리(markitdown[docx,pdf,xls,xlsx])를 감쌉니다. ppt/pptx는 먼저 normalize_ppt_bytes로 정규화합니다. 우선 keep_data_uris=True로 변환하고(이미지를 data URI로 남겨 후속 MarkdownImageBase64에서 추출), 실패하면 keep_data_uris=False로 폴백합니다. pptx 변환 후 markdown에 미해결 이미지 참조가 남으면 attach_pptx_media_to_markdown으로 이미지를 보완합니다. 전체 동작은 parser_worker_limit("markitdown", DOCREADER_MARKITDOWN_MAX_WORKERS=1)로 제한됩니다. 한계: MarkItDown의 PDF는 pdfminer로 텍스트를 추출하므로 스캔 문서를 처리할 수 없습니다(parse_local.py --scanned 주석에는 pdfminer가 멈출 수 있다는 설명도 있음). 표/레이아웃 복원은 builtin PDF 라우팅보다 약합니다.
opendataloader_parser.py — OpenDataLoaderParser(opendataloader 엔진, PDF 전용)
Apache-2.0 opendataloader-pdf(Java로 구현된 레이아웃 분석)를 감쌉니다. convert()마다 JVM을 시작하고(parser_worker_limit("opendataloader", 1)로 제한) markdown + 외부 이미지 디렉터리를 출력합니다. 이어서 출력 트리의 모든 이미지를 수집하고 별칭 표(꺾쇠괄호로 감싼 <images/foo.png>, HTML 엔터티, basename, imageFileN 번호 매칭)를 만들어 markdown 이미지 참조를 다시 씁니다. hybrid 모드(DOCREADER_ODL_HYBRID=docling-fast 등)를 지원하며, 별도로 배포한 opendataloader-pdf-hybrid HTTP 서비스(DOCREADER_ODL_HYBRID_URL, 기본값 http://127.0.0.1:5002, Docker 쪽은 docker/Dockerfile.odl-hybrid)를 호출합니다. 가용성 프로브에는 재시도가 있습니다(빠른 확인 2s×1회, 파싱 전 확인 5s×6회로 서비스 콜드 스타트 허용). 출력 텍스트가 <20자이면 실패로 판단하고 builtin의 PDFScannedParser로 폴백합니다. 가용성 확인 조건: PATH에 java 존재(Java 11+ 필요, 이미지에는 openjdk-17-jre-headless 설치) + Python 패키지 설치 + hybrid 정상 상태.
파서 선택 결정 흐름
이미지 처리와 멀티모달 역할 분담
docreader 측 이미지 계약은 매우 단순합니다. 각 파서는 이미지를 Document.images = {"images/<文件名>": "<base64>"}로 반환하고, markdown 본문에서는  상대 참조를 사용합니다.
main.py의 두 반환 경로:
- unary
Read:_resolve_images()가 모든 이미지 base64를ImageRef.image_data인라인 바이트로 디코딩하여 한 번에 반환합니다.image_dir_path는 항상 빈 값입니다. 과거의 ‘공유 볼륨 디렉터리에 쓰기’ 방식은 폐기되었고, 주석에는 *‘설정된 스토리지 백엔드(local/minio/cos/tos)에 이미지를 영속화하는 책임은 전적으로 Go App에 있다’*고 명시되어 있습니다. - streaming
ReadStream:_iter_image_refs()가 한 장씩 yield하고 전송하면서pop으로 메모리를 해제합니다.
Go가 넘겨받은 뒤(internal/infrastructure/docparser/image_resolver.go) inline bytes를 객체 스토리지에 업로드하고 markdown의 images/... 참조를 스토리지 URL로 다시 씁니다. 이어서 internal/application/service/image_multimodal.go가 metadata의 image_source_type에 따라 결정합니다. scanned_pdf의 전체 페이지 이미지는 OCR(전용 ocr_prompt 포함), 일반 삽화는 VLM caption을 사용합니다. docreader 안에는 VLM 호출이 전혀 없습니다. models/read_config.py의 vlm_config/storage_config 필드는 구형 생성자 시그니처와의 호환성을 위해 남겨 둔 껍데기일 뿐입니다(‘하위 호환성을 위해 유지하는 레거시 설정’).
splitter/ 청크 분할기와 Go 측 chunker의 관계
docreader/splitter/splitter.py의 TextSplitter는 보호 패턴을 갖춘 재귀 청크 분할기입니다.
- 기본값은
chunk_size=512,chunk_overlap=80입니다. 코드 주석은 **‘internal/infrastructure/chunker/splitter.go(DefaultChunkOverlap = 80, DefaultChunkSize = 512)와 일치시킨다. 이제 Go splitter가 프로덕션 경로이며, 이 Python splitter는 여전히 사용하는 docreader sidecar를 위해 유지한다.’**고 명시합니다. 즉 프로덕션 경로의 청크 분할은 Go 측(internal/infrastructure/chunker/, heading_splitter, heuristic_splitter, header_tracker 등 포함)에서 수행합니다. Python 버전은 sidecar 시나리오/로컬 디버깅용으로만 유지하며 양쪽 알고리즘과 기본값을 맞춥니다. - 분할 흐름: 구분자 우선순위(
\n,。, 공백, 최종적으로 문자 단위)에 따라 재귀 분할 →protected_regex로 나눌 수 없는 조각 추출($$...$$수식,이미지,[](...)링크, Markdown 표 헤더+본문 행, 코드 블록 시작 부분) →_join으로 보호 조각의 완전성 보장 →_merge가 chunk_size/overlap에 따라 병합하고(start, end, text)튜플 생성(restore_text로 원문을 손실 없이 복원 가능). splitter/header_hook.py의HeaderTracker는 병합 시 Markdown 표 헤더를 추적합니다. 새 청크가 표 본문 중간에서 시작하면 헤더(구분 행 포함)를 청크 앞에 자동으로 추가합니다. 열 수가 다르면 추가하지 않습니다(header_column_mismatch). 빈 헤더 행은 첫 데이터 행으로 열 이름을 보완하며 Go 측 header_tracker와 동작이 같습니다. 따라서 RAG가 검색한 표 청크에는 열 이름 문맥이 함께 들어 있습니다.
gRPC 응답은 더 이상 chunks를 반환하지 않습니다(ReadResponse에 chunk 필드 없음). ExcelParser가 Document.chunks에 행별 chunk를 넣기는 하지만 주 경로는 content만 사용합니다.
전체 설정 항목
config.py(DocReaderConfig, 시작 시 적용값 출력)
| 환경 변수(별칭) | 기본값 | 설명 |
|---|---|---|
DOCREADER_GRPC_MAX_WORKERS(GRPC_MAX_WORKERS) | 4 | gRPC 스레드 풀 동시 실행 수 |
DOCREADER_GRPC_MAX_FILE_SIZE_MB(MAX_FILE_SIZE_MB) | 50(MB) | gRPC 송수신 메시지 상한(바이트로 환산) |
DOCREADER_GRPC_PORT(PORT) | 50051 | gRPC 수신 포트 |
DOCREADER_DOCX_MAX_PAGES | 0(제한 없음) | DOCX 최대 처리 페이지 수 |
DOCREADER_MARKITDOWN_MAX_WORKERS | 1 | MarkItDown 동시 실행 제한(≤0이면 제한 해제) |
DOCREADER_ODL_MAX_WORKERS | 1 | OpenDataLoader(JVM) 동시 실행 제한 |
DOCREADER_ODL_HYBRID | off | ODL hybrid 모드(예: docling-fast) |
DOCREADER_ODL_HYBRID_URL | http://127.0.0.1:5002 | hybrid 서비스 주소 |
DOCREADER_ODL_HYBRID_MODE | auto | hybrid 모드 매개변수 |
DOCREADER_ODL_HYBRID_FALLBACK | false | hybrid 실패 시 폴백 여부 |
DOCREADER_ODL_MARKDOWN_WITH_HTML | false | ODL markdown의 HTML 허용 |
DOCREADER_PDF_RENDER_MAX_WORKERS | 1 | PDF 렌더링 단계 제한(요청 간) |
DOCREADER_PDF_RENDER_PARALLELISM | min(4, cpu) | 단일 PDF 내 스캔 페이지 렌더링 worker 프로세스 수 |
DOCREADER_PDF_RENDER_DPI | 200 | 스캔 페이지 렌더링 DPI |
DOCREADER_PDF_JPEG_QUALITY | 85 | 페이지 이미지 JPEG 품질 |
DOCREADER_PDF_RENDER_MAX_EDGE | 2000 | 렌더링/추출 이미지 긴 변 픽셀 상한(0은 제한 없음) |
DOCREADER_EXTERNAL_HTTP_PROXY / DOCREADER_EXTERNAL_HTTPS_PROXY(EXTERNAL_HTTP_PROXY/EXTERNAL_HTTPS_PROXY) | 빈 값 | 외부망 프록시(WebParser, DOC 변환 자식 프로세스) |
DOCREADER_IMAGE_OUTPUT_DIR(IMAGE_OUTPUT_DIR) | /tmp/docreader | 임시 이미지 디렉터리(local 모드 폴백용, 현재 주 경로는 디스크에 쓰지 않음) |
PDF 라우팅 세부 설정(pdf_parser.py 모듈 수준 환경 변수 중 자주 쓰는 항목)
| 환경 변수 | 기본값 | 설명 |
|---|---|---|
DOCREADER_PDF_SCAN_IMAGE_RATIO | 0.5 | 이미지 면적 비율 ≥ 이 값이면 스캔 페이지로 판단 |
DOCREADER_PDF_SCAN_MIN_CHARS | 10 | 이 문자 수보다 적으면 사용 가능한 텍스트 레이어가 없는 것으로 판단 |
DOCREADER_PDF_FORCE_SCANNED | false | 모든 페이지를 스캔으로 처리(업로드별 override pdf_force_scanned도 가능) |
DOCREADER_PDF_EXTRACT_EMBEDDED_IMAGES | true | text 페이지에서 내장 삽화 추출 |
DOCREADER_PDF_EMBED_MIN_PIXELS / _EMBED_MIN_AREA_RATIO / _EMBED_REPEAT_PAGE_FRAC / _EMBED_MAX_IMAGES | 80 / 0.01 / 0.5 / 50 | 내장 이미지 필터링: 최소 변 길이 / 페이지 면적 비율 / 로고 판정 반복률 / 문서당 상한 |
DOCREADER_PDF_LAYOUT_ORDERING | true | 기하 기반 레이아웃 재구성(다단 읽기 순서) |
DOCREADER_PDF_DETECT_HEADINGS | true | 글꼴 크기 휴리스틱 제목 인식 |
DOCREADER_PDF_FILTER_HIDDEN_TEXT | true | 보이지 않는/페이지 밖 텍스트 필터링(prompt injection 방지) |
DOCREADER_PDF_SANITIZE_TEXT / _STRIP_CHART_DEBRIS | true | 자리표시 문자 / 차트 조각 행 정리 |
DOCREADER_PDF_RENDER_VECTOR_FIGURES | true | 벡터 차트 영역을 JPEG로 렌더링 |
DOCREADER_PDF_WORD_GAP_WIDTH_RATIO / _MARGIN_COL_WIDTH_RATIO / _MIN_HEADING_LINE_CHARS 등 | 0.4 / 0.12 / 8 | 레이아웃 재구성 미세 조정 매개변수(소스 코드 상수 영역 참고) |
보안과 기타
| 환경 변수 | 설명 |
|---|---|
GRPC_AUTH_TOKEN | 설정하면 token 인증 활성화(metadata authorization: Bearer <token>) |
GRPC_TLS_ENABLED / GRPC_TLS_CERT / GRPC_TLS_KEY / GRPC_TLS_CA / GRPC_MTLS_REQUIRE_CLIENT_CERT | TLS / mTLS, 설정이 잘못되면 시작 거부 |
SSRF_WHITELIST / SSRF_WHITELIST_EXTRA | SSRF 허용 목록(쉼표로 구분, *.suffix와 CIDR 지원) |
LOG_LEVEL | 로그 수준(기본 INFO, 로그 형식에 request_id와 소요 시간 포함, utils/request.py 참고) |
LIBREOFFICE_PATH / ANTIWORD_PATH | soffice / antiword 실행 파일 경로 재정의 |
배포와 확장 권장 사항
이미지와 시스템 의존성(docker/Dockerfile.docreader)
기본 이미지는 python:3.10.18-bookworm이며 2단계 빌드를 사용합니다(builder에서 uv sync --locked로 의존성 설치 + scripts/generate_proto.sh로 pb 코드 생성, runner에서 venv 복사). EXPOSE 50051, CMD ["uv", "run", "-m", "docreader.main"]입니다. 실행 단계 시스템 의존성:
- LibreOffice(doc→docx, ppt→pptx, 비정상 스프레드시트→xlsx 변환) + 여러 X/글꼴 라이브러리(libxinerama1, libfontconfig1, libcairo2, libcups2 등).
- antiword(.doc 일반 텍스트 폴백).
- openjdk-17-jre-headless(OpenDataLoader PDF에 Java 11+ 필요).
- Playwright WebKit:
python -m playwright install webkit+install-deps webkit. 이미지에서 유일한 ‘모델/브라우저 바이너리 다운로드’ 단계입니다(경량화 후 OCR 모델 다운로드 없음, Dockerfile 주석에 ‘OCR/PaddleOCR 관련 의존성 제거’ 명시). - grpc_health_probe(컨테이너 오케스트레이션 상태 확인용 gRPC 상태 프로브).
- ImageMagick
convert가 있으면pptx_media.py가 WMF/EMF 래스터화에 사용합니다(선택적 보강 기능).
scripts/에는 두 도구도 있습니다. generate_proto.sh는 grpc_tools.protoc로 Python/Go 코드를 생성하고 import 경로를 수정합니다. parse_local.py는 gRPC를 거치지 않고 로컬에서 Parser를 직접 호출하여 파싱 결과를 디버깅하며, --engine, --scanned, --out으로 markdown과 이미지 내보내기를 지원합니다.
Python 의존성(pyproject.toml + uv.lock으로 고정): grpcio, pypdfium2, markitdown[docx,pdf,xls,xlsx], opendataloader-pdf, python-docx, pandas/openpyxl/xlrd, playwright, trafilatura, beautifulsoup4/markdownify/lxml, ebooklib, pillow, pydantic, textract(비활성화된 경로) 등.
확장과 튜닝
- 수평 확장 우선: pdfium 전역 잠금으로 단일 인스턴스 내 PDF 파싱은 직렬 실행됩니다. PDF 처리량은 주로 복제본 확장으로 높입니다. Go 클라이언트는
dns:///+round_robin으로 dial하며, K8s에서는 headless service로 여러 복제본에 부하를 균등 분산할 수 있습니다. - 단일 인스턴스 수직 튜닝: CPU가 충분하면
DOCREADER_PDF_RENDER_PARALLELISM(단일 문서 렌더링이 거의 선형으로 가속)과DOCREADER_GRPC_MAX_WORKERS(PDF 이외 형식은 실제 동시 실행 가능)를 늘립니다. 메모리가 제한되면 Go 측에서ReadStream을 사용하는지 우선 확인합니다(기본 동작). - 대용량 파일:
MAX_FILE_SIZE_MB는 Go 클라이언트와 docreader 양쪽을 함께 조정해야 합니다. 스캔 페이지 이미지 크기는DOCREADER_PDF_RENDER_MAX_EDGE/_DPI/_JPEG_QUALITY세 설정으로 제어합니다. - JVM/브라우저 부하 격리: OpenDataLoader는 파싱마다 JVM을, WebParser는 WebKit을 시작하므로 모두 무거운 프로세스입니다.
DOCREADER_ODL_MAX_WORKERS,DOCREADER_MARKITDOWN_MAX_WORKERS의 기본값 1은 보수적인 값이며, 자원이 충분하면 늘리거나 ≤0으로 설정해 제한을 해제할 수 있습니다. ODL hybrid 서비스(Dockerfile.odl-hybrid)는 별도로 배포하고DOCREADER_ODL_HYBRID_URL을 설정해야 합니다. - 타임아웃 보호: Go 측에서 반드시
docreader_call_timeout(internal/config/config.go)을 설정해야 합니다. 그렇지 않으면 멈춘 docreader가 수집 worker를 장시간 점유합니다. - 보안 기준: 프로덕션에서는
GRPC_AUTH_TOKEN(≥16바이트) +GRPC_TLS_ENABLED를 켭니다. 설정하지 않으면 평문 + 인증 없음 모드로 시작하고 WARNING을 출력합니다.
anydoc 엔진(Go 프로세스 내부 파싱, docreader 미사용)
anydoc은 Go 측 선택적 파싱 엔진입니다. anydoc(Rust로 작성된 문서 변환 라이브러리)을 cgo로 WeKnora 메인 프로세스에 링크하여 office 문서를 Markdown으로 직접 변환합니다. 이 문서의 나머지 부분에서 설명한 docreader와 달리 Python 서비스를 거치지 않고, 프로세스 경계를 넘지 않으며, 외부 바이너리도 호출하지 않습니다. docreader를 배포하지 않는 경량 배포나 파싱 지연에 민감한 시나리오에 적합합니다.
지원 파일 유형: doc, docx, docm, odt, rtf, ppt, pptx, pptm, odp, xls, xlsx, xlsm, ods, epub, csv, pdf.
활성화 방법
파싱 라이브러리는 Rust 정적 라이브러리이므로 빌드에 Rust 도구 체인이 필요합니다. 공식 Docker 이미지(wechatopenai/weknora-app)와 docker compose build는 anydoc을 기본 링크하므로 설정 페이지에서 바로 선택할 수 있습니다. 로컬 go build는 기본적으로 링크하지 않습니다. -tags anydoc을 추가하지 않으면 ‘파싱 엔진’ 목록에서 사용할 수 없는 것으로 표시되며 다른 엔진에는 영향이 없습니다.
make build-anydoc # 정적 라이브러리 + anydoc 태그 바이너리 빌드
# 다음과 동일:
scripts/build-anydoc-lib.sh && go build -tags anydoc ./cmd/serverDocker 이미지의 기본값은 WITH_ANYDOC=1입니다. Rust 도구 체인을 건너뛰고 빌드 시간을 줄이려면:
docker build -f docker/Dockerfile.app --build-arg WITH_ANYDOC=0 -t weknora-app .
# 또는 .env에 WITH_ANYDOC=0을 설정한 뒤 docker compose build 실행명시적 파싱 규칙이 우선합니다. 규칙이 없으면 anydoc이 링크되어 있고 지원하는 복잡한 형식은 기본적으로 anydoc을 우선 사용합니다. 단순 형식은 계속 Go SimpleFormatReader를 사용합니다. anydoc이 링크되지 않은 PPT/PPTX는 기본적으로 markitdown으로 폴백합니다. 엔진을 고정해야 한다면 배포의 컴파일 옵션에 의존하지 말고 지식 베이스 파싱 설정에서 명시적으로 지정할 수 있습니다.
기능 범위와 한계
- 스캔 PDF: anydoc은 PDF 텍스트 레이어만 추출합니다. 텍스트 레이어가 없는 스캔 문서는 ‘OCR이 필요해요’ 오류를 반환합니다. DocReader(builtin)가 연결되어 있으면 AnydocReader는 해당 파일을 builtin에 자동으로 넘기고, 페이지별 JPEG 렌더링과
image_source_type=scanned_pdf표시 후 기존 Go 측 OCR을 수행합니다. DocReader가 연결되지 않으면 변환에 실패하므로builtin,mineru또는paddleocr_vl을 사용하세요. - 세로 병합 셀을 채우지 않음: docreader의
Docx2Parser는 세로 병합 값을 각 행에 복사하지만(issue #2634 참고), anydoc은 시작 행에만 값을 출력하고 이후 행은 비워 둡니다. 표의 행별 의미에 의존하는 지식 베이스에는builtin이 여전히 더 안정적입니다. - 이미지 위치: 이미지 추출이 켜져 있으면 문서 모델의 내장 이미지를 먼저
images/image-N.ext링크로 바꾼 뒤 anydoc 공식 GFM 직렬화기에 전달합니다. 따라서 이미지는 원래 문단/표/목록 위치에 남습니다. 엔진 재정의 매개변수anydoc_extract_images=false를 설정하면 이미지 추출을 끄고 더 빠른 일반 텍스트 렌더링을 사용합니다(내장 이미지는 alt 텍스트로 대체). - URL, 이미지, 오디오 미처리: 이 항목은 기존처럼
WebParser,SimpleFormatReader, ASR 경로가 담당합니다.
코드 위치
| 경로 | 역할 |
|---|---|
internal/infrastructure/docparser/anydoc/ | 어댑터 계층: 형식 매핑, 가용성 판단, cgo / stub 두 백엔드 |
internal/infrastructure/docparser/anydoc_reader.go | DocReader 구현: 변환 결과와 이미지 참조 조립 |
internal/infrastructure/docparser/engines.go | 엔진 등록(메타데이터 + Reader 팩터리) |
third_party/anydoc-go/ | vendoring한 업스트림 Go 바인딩과 C ABI shim(출처와 로컬 변경은 해당 디렉터리 README 참고) |
부록: 핵심 사실 빠른 참조
- 외부 인터페이스: gRPC만 사용, 포트
50051(DOCREADER_GRPC_PORT/PORT), RPC:Read/ReadStream/ListEngines+ 표준 Health 서비스. - docreader가 직접 지원하는 전체 파일 형식:
pdf,docx,doc,xlsx,xls(markitdown 엔진은pptx,ppt,csv추가 지원),md/markdown,epub,html/htm,mhtml, 이미지jpg/jpeg/png/gif/bmp/tiff/webp, URL 웹페이지 수집.txt/csv/json/이미지/오디오는 주 경로에서 Go의SimpleFormatReader가 직접 처리하며 이 서비스를 거치지 않습니다. - OCR / VLM: docreader 내부에는 OCR과 VLM이 전혀 없습니다. 스캔 페이지와 삽화를 이미지로 반환하고 OCR(PaddleOCR-VL)과 caption은 Go App이 수행합니다.
- 이미지 반환: inline bytes(
ImageRef.image_data), local/minio/cos/tos 영속화는 Go가 담당합니다. - 청크 분할: 프로덕션 경로는 Go 측 chunker이며, Python
TextSplitter(512/80)는 sidecar용으로만 유지하고 Go와 일치시킵니다.