문제
OpenCairn은 PDF 하나를 올렸다고 끝나는 앱이 아닙니다.
제가 만들고 싶었던 것은 파일을 저장하는 기능보다, 자료가 나중에 검색되고, 근거로 인용되고, AI 작업의 입력이 될 수 있는 구조였습니다. 그래서 ingest는 단순 upload handler가 아니라 지식 파이프라인의 시작점입니다.
처음에 생각하기 쉬운 구조는 이렇습니다.
upload file
-> parse text
-> save note
하지만 이 정도로는 부족했습니다. 실제로는 사용자가 업로드한 원본, 파싱된 markdown, 페이지별 결과, 그림/표 artifact, AI 분석 노트, RAG chunk index가 서로 연결되어야 합니다.
현재 OpenCairn ingest는 대략 이런 방향입니다.
upload
-> object storage
-> source bundle tree node
-> Temporal IngestWorkflow
-> parser/OCR/STT/web/youtube/office activity
-> source note
-> CompilerWorkflow
-> note analysis jobs
-> note_chunks
-> RAG and agentic evidence
API에서 시작한다
업로드 진입점은 apps/api/src/routes/ingest.ts입니다.
API route는 먼저 파일 타입과 크기를 확인하고, object storage에 원본을 올립니다. 그 다음 ingest_jobs에 workflow owner metadata를 저장하고 Temporal IngestWorkflow를 시작합니다.
PDF일 때는 여기서 중요한 일이 하나 더 일어납니다. workflow가 돌기 전에 source_bundle project tree node를 먼저 만듭니다.
POST /api/ingest/upload
-> validate file
-> upload object
-> create workflow id
-> create source bundle if PDF
-> insert ingest_jobs row
-> start IngestWorkflow
-> return workflowId + sourceBundleNodeId
이 순서를 택한 이유는 UX 때문입니다. 사용자는 worker가 끝날 때까지 빈 화면을 보는 것이 아니라, 업로드 직후 project tree에서 "이 PDF가 처리 중이다"라는 물체를 볼 수 있어야 합니다.
source bundle은 파일 하나의 작업대다
최근 구조에서 중요한 테이블은 project_tree_nodes입니다.
기존에는 folder와 note 중심으로 프로젝트 사이드바를 볼 수 있었습니다. 그런데 PDF ingest를 제대로 보여주려면 파일 하나 아래에 child artifact가 필요했습니다.
그래서 tree node kind가 넓어졌습니다.
folder
note
agent_file
code_workspace
source_bundle
artifact_group
artifact
PDF 하나를 올리면 대략 이런 tree가 됩니다.
project
-> 1주차.pdf source_bundle
-> original.pdf agent_file
-> Parsed artifact_group
-> parsed.md
-> page-1.md
-> page-2.md
-> Figures artifact_group
-> figure-1.png
-> Analysis artifact_group
-> AI summary note note
여기서 tree node는 hierarchy와 표시 순서를 책임지고, 실제 content bytes는 agent_files, notes, object storage 같은 기존 테이블이 계속 책임집니다. 이 분리가 마음에 들었습니다. tree가 content table을 대체하지 않고, 사용자가 보는 project object layer가 됩니다.
Temporal workflow가 타입별로 분기한다
실제 긴 작업은 apps/worker/src/worker/workflows/ingest_workflow.py의 IngestWorkflow가 맡습니다.
workflow는 MIME type을 보고 activity를 나눕니다.
application/pdf
-> parse_pdf
audio/* or video/*
-> transcribe_audio
image/*
-> analyze_image
x-opencairn/youtube
-> ingest_youtube
x-opencairn/web-url
-> scrape_web_url
text/plain or text/markdown
-> read_text_object
Office files
-> parse_office
HWP/HWPX
-> parse_hwp
이 분기를 API route에 두지 않은 이유는 간단합니다. 파싱은 오래 걸리고 실패할 수 있고 retry가 필요합니다. 특히 PDF/OCR, Office 변환, STT, YouTube, web scrape는 request-response 안에 넣기 어렵습니다.
Temporal을 쓰면 각 activity에 timeout, retry, heartbeat를 줄 수 있습니다. 긴 작업이 멈췄는지, 실패했는지, 다시 시도할 수 있는지 구분할 수 있습니다.
PDF는 page artifact를 남긴다
PDF는 source bundle의 효과가 가장 잘 드러나는 경로입니다.
parse_pdf 결과에는 전체 text뿐 아니라 markdown, page_artifacts, figure_artifacts 같은 구조가 포함될 수 있습니다. workflow는 _materialize_pdf_artifacts()에서 이것을 durable child artifact로 바꿉니다.
parse_pdf result
-> parsed.md artifact
-> page markdown artifacts with page_index
-> figure artifacts with page_index / figure_index
여기서 page_index가 중요합니다. RAG 관점에서는 chunk와 source offset이 중요하지만, 사용자가 원본 자료를 볼 때는 "몇 페이지에서 나온 근거인가"가 더 자연스럽습니다.
OpenCairn은 이 둘을 모두 가져가려고 합니다.
page_index
-> 사람이 보는 PDF/page artifact 위치
sourceOffsets / chunkIndex
-> RAG와 citation이 쓰는 text 위치
둘 중 하나만 있으면 불편합니다. page index만 있으면 chunk-level retrieval이 약해지고, chunk offset만 있으면 사용자가 PDF 안에서 위치를 감각적으로 찾기 어렵습니다.
live ingest event
ingest는 오래 걸립니다. 그래서 진행 상태를 worker 로그에만 남기면 안 됩니다.
OpenCairn은 worker activity가 Redis에 event를 publish하고, API SSE가 브라우저로 전달하는 구조를 씁니다.
worker activity
-> publish_safe(workflowId, kind, payload)
-> Redis pub/sub + replay list
-> GET /api/ingest/stream/:workflowId
-> browser EventSource
-> ingest progress UI
event kind는 started, stage_changed, unit_started, unit_parsed, figure_extracted, artifact_created, bundle_status_changed, completed, failed 같은 형태입니다.
이 구조에서 worker는 SSE connection을 직접 들고 있지 않습니다. worker는 처리에 집중하고, API가 인증과 replay/live tail을 책임집니다.
source note와 CompilerWorkflow
파싱이 끝나면 create_source_note activity가 /api/internal/source-notes를 호출합니다.
여기서 source note가 만들어지고, payload에는 triggerCompiler: true가 들어갑니다. 즉 ingest는 source note 생성에서 끝나는 것이 아니라, LLM wiki system으로 이어집니다.
parsed text
-> create_source_note
-> POST /api/internal/source-notes
-> notes row
-> trigger CompilerWorkflow
이 연결이 중요합니다. ingest가 "텍스트 저장"에서 멈추면 RAG에는 쓸 수 있어도 wiki system에는 들어가지 않습니다. OpenCairn에서는 source note가 concept graph와 wiki log로 이어져야 합니다.
note analysis job과 RAG index
source note가 생겼다고 바로 RAG evidence가 완성되는 것은 아닙니다.
RAG가 쓰는 단위는 note_chunks입니다. note chunk는 note content를 heading/context 기준으로 쪼개고, embedding과 content_tsv를 붙인 검색 단위입니다.
이 refresh는 note_analysis_jobs가 담당합니다.
note created or changed
-> queue note_analysis_jobs row
-> check contentHash + Yjs state vector
-> buildNoteChunkRows()
-> delete old note_chunks
-> insert fresh chunks
-> mark completed
여기서 stale check가 들어가는 이유는 노트가 계속 바뀔 수 있기 때문입니다. job이 시작된 뒤 사용자가 노트를 고쳤다면, 예전 본문 기준 chunk를 commit하면 안 됩니다.
실패 경로도 제품 상태다
ingest는 실패할 수밖에 없습니다.
PDF가 깨져 있을 수 있고, OCR이 실패할 수 있고, Office 변환이 느릴 수 있고, object storage나 Redis가 일시적으로 안 될 수도 있습니다. 그래서 workflow는 실패 시 quarantine/report path를 가지고, source bundle status도 failed로 바꿉니다.
activity failed
-> quarantine source if object exists
-> report ingest failure
-> publish failed event
-> update source bundle status failed
-> Temporal workflow failed
이게 단순 error log보다 중요합니다. 사용자는 "내 파일이 어디서 실패했는지"를 project tree와 progress UI에서 알아야 합니다.
구현하며 배운 점
ingest는 생각보다 제품의 중심에 가깝습니다.
처음에는 파일을 텍스트로 바꾸는 작업이라고 봤는데, 지금은 이렇게 보고 있습니다.
ingest = 원본 자료를 project object, wiki source, RAG evidence로 바꾸는 과정
그래서 OpenCairn ingest에는 여러 층이 필요했습니다.
- object storage에는 원본이 남아야 한다.
- project tree에는 사용자가 볼 수 있는 source bundle이 생겨야 한다.
- worker는 타입별 parser/OCR/STT를 retry 가능한 workflow로 실행해야 한다.
- PDF는 page artifact와 figure artifact를 남겨야 한다.
- source note는 LLM wiki system으로 이어져야 한다.
- note analysis job은 RAG chunk index를 fresh하게 만들어야 한다.
- 실패 상태도 UI와 Workflow Console에서 추적 가능해야 한다.
RAG 품질을 말하려면 ingest 품질을 같이 봐야 합니다. 파싱이 흔들리면 chunk가 흔들리고, chunk가 흔들리면 citation과 agentic evidence도 같이 흔들리기 때문입니다.