Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/AI/EN

Search Tool for Scanned Textbooks and Uncaptioned Lectures

스캔 교재와 무자막 강의를 출처까지 찾는 검색 도구

스캔 교재와 무자막 강의를 출처까지 찾아 주는 검색

검색 안 되는 스캔본과 무자막 강의

다뤄야 할 수업 자료는 두 갈래입니다. 하나는 스캔해서 글자를 검색할 수 없는 PDF 교재, 다른 하나는 자막이 없는 영어 강의 영상이었어요.

요구는 네 가지로 정리됐습니다. 지정된 이메일 도메인 계정만 들어올 것, 교수가 배포한 과목 자료와 학생이 올린 개인 자료가 섞이지 않을 것, LLM 답변에는 반드시 페이지나 타임스탬프 출처가 붙을 것, 그리고 상시 켜 두는 서버 없이 굴러갈 것. 마지막 조건이 사실상 스택을 결정했습니다.

시스템 경계와 외부 의존
시스템 경계와 외부 의존

서버 없이 돌리는 인덱싱과 챗

Cloudflare 한 계정 안에서 끝내는 구성: Workers, D1, R2, Queues, Workflows, Vectorize, Workers AI, Containers

상시 서버를 살 수 없었고, 인증·스토리지·큐·벡터 검색이 전부 바인딩으로 붙으면 붙일 인프라 코드가 거의 없어집니다. 워커끼리는 서비스 바인딩으로 부르니 내부 통신에 공개 엔드포인트를 만들 필요도 없었어요. OCR 컨테이너(study-ocr)는 Workers Paid 플랜이 있어야 이미지를 올릴 수 있고, Worker 메모리는 128MB로 묶여 있습니다.

로그인을 앱이 아니라 Cloudflare Access에 맡기고, 앱은 JWT 검증만 한다

허용할 이메일 도메인 Allow 정책 한 줄이면 접근 통제가 끝납니다. apps/api/src/auth.ts는 Cf-Access-Jwt-Assertion을 JWKS(10분 캐시)로 검증하고 email 클레임으로 사용자를 찾거나 학생으로 자동 생성해요. 비밀번호도, 세션 테이블도 만들지 않았습니다. 로컬에서는 Access를 흉내 낼 수 없어서 AUTH_DEV_BYPASS=1 + study_dev_user 쿠키로 시드 계정을 갈아 끼웁니다.

인덱싱은 요청 밖으로 빼서 Queue 한 건 + Workflows 여러 step으로

OCR은 90분, 자막 번역은 60분까지 잡아 둔 작업입니다. 요청-응답 수명 안에서 돌 수 없어요. Workflows의 step 단위로 타임아웃과 재시도를 따로 걸고, 이미 만들어 둔 cues-en.json이 있으면 STT를 건너뛰고 번역만 다시 돌립니다. 라이브 큐 컨슈머는 하나여야 해서 PR 프리뷰에는 study-ingest를 올리지 않습니다.

채팅은 Agents SDK Durable Object로, (사용자, 스코프) 조합마다 인스턴스 하나

대화 이력을 DO 상태로 두면 세션 테이블이 필요 없고, WebSocket으로 status → context → delta → answer를 그대로 흘려보낼 수 있습니다. 자료 화면, 과목, 노트북, 전체가 각각 다른 방이 되죠. DO 이름이 곧 격리 경계라서, 이름을 클라이언트가 정하게 두면 그 순간 남의 방에 들어갈 수 있습니다.

임베딩은 Workers AI의 EmbeddingGemma, 답변 생성은 OpenRouter

임베딩은 인덱싱 때 수천 번 부르니 계정 안에서 돌아야 싸고 빠릅니다. 반대로 답변 모델은 자주 갈아 끼우고 싶었고, 채팅 입력창에서 모델 문자열을 바꿔 볼 수 있게 열어 뒀어요. 키가 없는 로컬에서는 검색 결과를 요약한 모의 응답을 같은 프로토콜로 스트리밍하고, Vectorize를 못 쓰면 D1 키워드 검색으로 내려갑니다.

배포와 인프라 구성
배포와 인프라 구성

API와 챗이 공유하는 권한 규칙 한 벌

앞단은 study-web 하나입니다. Workers Assets가 SPA를 내보내고, /api/*만 워커가 먼저 받아 study-api로 서비스 바인딩 프록시를 합니다. 그래서 브라우저 입장에서는 정적 파일도, API도, WebSocket도 전부 같은 오리진이에요.

study-api는 Hono 하나로 CRUD(D1), R2 멀티파트 업로드, 큐 발행, FSRS 채점, 에이전트 프록시를 담당합니다. 파일은 절대 R2 링크로 넘기지 않고 API가 Range 요청을 받아 되돌려 줍니다. PDF 뷰어의 페이지 점프와 영상 탐색이 그 위에서 돌아요.

업로드가 끝나면 study-ingest 큐 메시지 한 건이 나가고, 컨슈머가 책이면 BookIngestWorkflow, 영상이면 VideoIngestWorkflow를 띄웁니다. 책은 PDF를 OCR 컨테이너로 흘려보내 NDJSON을 되받고, 영상은 ElevenLabs로 영어 자막을 뜬 뒤 한국어로 번역해 cues.json과 VTT 두 벌을 R2에 씁니다. 두 파이프라인 모두 마지막에는 청크를 임베딩해 Vectorize에, 행은 D1에 넣습니다. 진행률은 sources.ingest_stage와 ingest_progress에 계속 덮어써서 화면이 2.5초마다 폴링합니다.

질문이 들어오면 study-agent의 Durable Object가 자기 이름에서 사용자와 스코프를 되읽고, 읽을 수 있는 자료 id 목록을 만들어 Vectorize 필터에 넣은 다음 상위 8개 청크만 컨텍스트로 씁니다. 이 "읽을 수 있는가" 판단은 packages/access에 한 벌만 있고 API와 에이전트가 같은 함수를 부릅니다.

핵심 데이터 모델
핵심 데이터 모델

채팅방 이름을 서버가 다시 쓰게 만든 이유

브라우저가 /agents/notebook-agent/notebook-123처럼 방 이름을 정해서 붙고, 답변을 만든 뒤에 권한을 확인해 걸러 냅니다. 이러면 노트북 하나에 방이 하나라서 그 노트북을 볼 수 있는 사람들의 대화가 한 DO에 섞이고, 주소만 바꿔 치면 남의 방에 접속됩니다. 권한 검사도 API 쪽과 에이전트 쪽에 두 벌이 생겨 서서히 어긋나죠.

브라우저는 스코프만 말합니다(all / course:<id> / notebook:<id> / source:<id>). API 프록시가 canUseScope로 그 스코프를 쓸 자격을 확인한 다음, URL 경로에서 이름 자리만 <userId>~<scope>로 갈아 끼워 에이전트에 전달합니다. DO는 this.name을 ~로 갈라 사용자 id와 스코프를 복원하고, 검색 전에 scopeSourceIds()로 허용 목록을 만들어 Vectorize source_id $in 필터에 넣습니다. 읽기 규칙 SQL은 packages/access/index.ts의 READABLE_WHERE 한 곳뿐이고 API와 에이전트가 같이 씁니다.

세션 상태가 (사용자, 스코프) 단위로 물리적으로 갈라지니 이력이 섞일 수 없고, 사용자 id는 URL 어디에도 없으니 주소를 위조해도 자기 방으로만 돌아옵니다. 권한 판단이 한 함수라서 "API에서는 막혔는데 채팅에서는 답이 나오는" 어긋남이 구조적으로 생기지 않아요.

자막 2천 줄을 번역하면서 줄 수를 한 줄도 잃지 않기

자막 전체를 한 번에 모델에 던지거나, 반대로 한 줄씩 부릅니다. 한 번에 던지면 모델이 짧은 문장을 알아서 합치거나 쪼개서 줄 수가 어긋나고, 그 순간 타임스탬프 정렬이 통째로 밀립니다. 한 줄씩 부르면 2천 번 호출에 앞뒤 문맥이 없어 용어가 문장마다 달라져요.

20줄씩 배치로 보내되 직전 5쌍의 EN/KO를 문맥으로 같이 실었습니다. 응답은 {"ko":[...]} JSON을 강제하고, 파서가 <think> 블록·코드펜스·앞에 붙은 번호를 걷어낸 뒤 길이가 넘치면 자르고 모자라면 원문 영어로 채워 개수를 맞춥니다(parseKoList). 배치가 그래도 실패하면 절반으로 쪼개 재귀로 다시 시도하고, 모델은 Gemini 2.5 Pro → Flash → DeepSeek 순으로 물러납니다. 한 줄까지 쪼개도 실패하면 그 줄만 영어로 남기고 넘어갑니다.

어떤 경로로 실패하든 반환되는 줄 수가 입력과 같다는 불변식이 유지됩니다. 큐 개수가 보존되니 cues.json의 start/end가 그대로 살아 있고, 그 위에서 만드는 약 45초 구간 청크의 t_start도 정확합니다. 인용 칩을 눌렀을 때 영상이 실제로 그 대사 지점으로 점프하는 건 이 불변식 덕분이에요. 실패의 단위도 영상 전체가 아니라 한 줄로 좁아집니다.

유저가 할 수 있는 일

교내 이메일로 처음 들어와 홈을 본다
교내 이메일로 처음 들어와 홈을 본다

초대 코드를 입력해 과목에 참여한다
초대 코드를 입력해 과목에 참여한다

과목을 개설하고 주차를 만든다
과목을 개설하고 주차를 만든다

PDF 교재를 올려 검색 가능한 자료로 만든다
PDF 교재를 올려 검색 가능한 자료로 만든다

강의 영상 링크를 붙여 이중 자막 자료로 만든다
강의 영상 링크를 붙여 이중 자막 자료로 만든다

자료에 질문하고 인용을 눌러 원문으로 점프한다
자료에 질문하고 인용을 눌러 원문으로 점프한다

복습 카드를 만든다: 자료 통째로, 또는 메모한 페이지만
복습 카드를 만든다: 자료 통째로, 또는 메모한 페이지만

오늘 밀린 카드를 채점한다
오늘 밀린 카드를 채점한다

자료를 노트북으로 묶어 함께 질문한다
자료를 노트북으로 묶어 함께 질문한다

내 자료를 과목에 공유 요청하고 승인을 받는다
내 자료를 과목에 공유 요청하고 승인을 받는다

자료를 주차에 붙이고 공개 시각을 예약한다
자료를 주차에 붙이고 공개 시각을 예약한다

과목 학습 현황을 보고 CSV로 내려받는다
과목 학습 현황을 보고 CSV로 내려받는다

관리자가 역할과 용량을 조정하고 감사 로그를 본다
관리자가 역할과 용량을 조정하고 감사 로그를 본다

1 / 1

그림 캡션은 여전히 0건

가장 크게 비어 있는 곳은 그림입니다. OCR 컨테이너는 페이지 텍스트와 함께 추출한 그림을 base64로 NDJSON에 실어 보내는데, 워커가 그 줄을 JSON.parse나 atob 없이 통째로 버립니다. CPU 예산 때문이었어요. 그 결과 figures.json이 늘 빈 배열이고 caption-figures 단계는 0건으로 끝납니다. VLM 캡션과 그림 인용은 코드로는 다 있는데 실제로는 돌지 않는 셈이죠. 다시 만든다면 컨테이너가 그림을 R2에 직접 PUT하고 워커에는 키만 흘려보내겠습니다. 지금 구조에서 바이트를 워커로 통과시키려 한 게 처음부터 어긋난 선택이었습니다.

영상 오디오도 워커 메모리 안에서 ElevenLabs로 넘어가므로 두 시간 강의쯤이 한계입니다. 교육자용 학습 현황은 요청 시 집계라 실시간이 아니고, KV 바인딩이 없으면 캐시도 없이 매번 계산합니다. 수백 명이 동시에 붙기 전에 사전 집계로 옮겨야 해요. jobs 테이블과 sources.area, 옛 review_state처럼 쓰지 않으면서 지우지도 않은 잔재도 남아 있습니다. 테스트는 Playwright e2e에 기대고 있는데, pnpm test:unit이 가리키는 packages/access/*.test.ts는 아직 존재하지 않습니다. 권한 규칙처럼 한 곳에 모아 둔 로직일수록 단위 테스트가 먼저 있어야 했습니다.

Read next

여행 동선을 지도에서 따라가는 프로모션 사이트

RIIZE — 2026