- Integrate ollama + ai-gateway into root docker-compose.yml (NVIDIA GPU runtime, single compose for all services) - Change NAS mount from SMB (NAS_SMB_PATH) to NFS (NAS_NFS_PATH) Default: /mnt/nas/Document_Server (fstab registered on GPU server) - Update config.yaml AI endpoints: primary → Mac mini MLX via Tailscale (100.76.254.116:8800) fallback/embedding/vision/rerank → ollama (same Docker network) gateway → ai-gateway (same Docker network) - Update credentials.env.example (remove GPU_SERVER_IP, add NFS path) - Mark gpu-server/docker-compose.yml as deprecated - Update CLAUDE.md network diagram and AI model config - Update architecture.md, deploy.md, devlog.md for GPU server as main - Caddyfile: auto_https off, HTTP only (TLS at upstream proxy) - Caddy port: 127.0.0.1:8080:80 (localhost only) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
46 KiB
PKM 웹앱 아키텍처 v2 — DEVONthink 탈피, 자체 시스템 구축
1. 설계 배경 및 동기
왜 DEVONthink을 버리는가
v1 시스템(DEVONthink 중심)의 6단계 개발 과정에서 드러난 구조적 한계:
- AppleScript가 유일한 프로그래밍 인터페이스. 16개 커밋 중 절반 이상이 AppleScript 실행 관련 버그 수정(POSIX path, f-string 이스케이프, -e 분할 등).
- DEVONthink의 실제 역할은 "파일 저장소 + Smart Rule 트리거"뿐. 분류/태그/요약/임베딩 등 지능은 전부 외부(MLX, GPU 서버)에 존재.
- macOS GUI 의존성으로 서버 이전 불가. OmniFocus도 동일한 문제.
- 13개 DB 관리의 복잡성, 웹 접근 불가, 모바일 접근 제한.
설계 원칙
- macOS 의존성 제거 — AppleScript, DEVONthink, OmniFocus 전부 탈피
- 컨테이너 기반 — 모든 서비스를 Docker로. 서버 간 이동이
docker compose up한 번 - AI 추상화 — OpenAI 호환 API 인터페이스로 통일. MLX/vLLM/Ollama 무관
- Synology 서비스 활용 극대화 — 직접 만들지 않아도 되는 것은 만들지 않는다
- 원본 단일 원칙 — 같은 정보가 두 곳에 존재하지 않는다
2. 기술 스택
| 영역 | 기술 | 선정 근거 |
|---|---|---|
| 백엔드 | FastAPI (Python) | 기존 Python 코드 1,500줄 재활용. 비동기 지원. OpenAPI 자동 문서화 |
| 데이터베이스 | PostgreSQL + pgvector | 메타데이터 + 전문검색 + 벡터 검색 통합. JSONB로 유연한 스키마 |
| 프론트엔드 | SvelteKit | 1인 개발에 적합한 낮은 보일러플레이트. 가벼운 빌드. SSR 지원 |
| 문서 파싱 | kordoc (Node.js) | HWP/HWPX/PDF → Markdown. MIT 라이선스. MCP 서버 지원 |
| 리버스 프록시 | Caddy | 자동 HTTPS, 설정 단순 |
| 인증 | JWT + TOTP 2FA | 1인 사용에 적합한 경량 인증. FastAPI 내장 |
| 검색 강화 | Meilisearch (선택) | 한국어 검색 품질 향상 필요시 Docker로 추가 |
| 컨테이너 | Docker Compose | 전 서비스 컨테이너화. 서버 이전 용이 |
3. 인프라 역할 분담
┌─────────────────────────────────────────────────────────────┐
│ Mac mini M4 Pro (애플리케이션 서버) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Docker Compose │ │
│ │ │ │
│ │ ┌───────────┐ ┌────────────┐ ┌──────────────────┐ │ │
│ │ │ FastAPI │ │ PostgreSQL │ │ kordoc-service │ │ │
│ │ │ (백엔드) │ │ + pgvector │ │ (문서 파싱) │ │ │
│ │ └─────┬─────┘ └────────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ ┌─────▼─────┐ ┌────────────┐ │ │
│ │ │ SvelteKit │ │ Caddy │ │ │
│ │ │ (프론트) │ │ (프록시) │ │ │
│ │ └───────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌────────────────┐ │
│ │ MLX Server │ │ Roon Core │ │
│ │ Qwen3.5-35B-A3B │ │ (음악 서버) │ │
│ │ localhost:8800 │ │ │ │
│ └──────────────────────┘ └────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
│ Tailscale
┌───────────────────┼───────────────────┐
┌───────▼─────────────┐ ┌──────────▼──────────────┐
│ Synology DS1525+ │ │ GPU 서버 (리눅스) │
│ │ │ │
│ ▸ 파일 원본 저장 │ │ ▸ nomic-embed-text │
│ (Document_Server) │ │ (벡터 임베딩) │
│ ▸ Synology Office │ │ ▸ Qwen2.5-VL-7B │
│ (문서 편집/미리보기)│ │ (이미지/도면 OCR) │
│ ▸ Synology Drive │ │ ▸ bge-reranker-v2-m3 │
│ (파일 동기화) │ │ (RAG 리랭킹) │
│ ▸ Synology Calendar │ │ ▸ Plex, Komga │
│ (태스크 - CalDAV) │ │ │
│ ▸ MailPlus │ │ │
│ (이메일 + 알림) │ │ │
│ ▸ Synology Chat │ │ │
└─────────────────────┘ └──────────────────────────┘
Mac mini 역할
- FastAPI 웹앱 서버 (API + 프론트엔드 서빙)
- PostgreSQL (메타데이터, 전문검색 인덱스, 벡터 임베딩)
- kordoc 마이크로서비스 (HWP/PDF 텍스트 추출)
- AI 추론 (MLX Qwen3.5-35B-A3B, 분류/태그/요약)
- Caddy 리버스 프록시 (외부 접근 + 자동 HTTPS)
- Roon Core (음악, PKM과 무관)
Synology NAS 역할
- 파일 원본 저장소 (
/volume4/Document_Server/) - Synology Office → 문서 편집 및 미리보기 (DOCX/XLSX/PPTX 대체)
- Synology Drive →
Document_Server/전체 관리 + 버전 이력 +Technicalkorea Document/동기화 - Synology Calendar → 태스크/일정 관리 (CalDAV VTODO, OmniFocus 대체)
- MailPlus → 이메일 수집 (IMAP) + 알림 발송 (SMTP)
GPU 서버 역할
- AI Gateway (모델 라우팅, 폴백 처리, 비용 제어, 요청 로깅)
- 벡터 임베딩 (nomic-embed-text)
- 이미지/도면 OCR (Qwen2.5-VL-7B)
- RAG 리랭킹 (bge-reranker-v2-m3)
- NanoClaw (선택적, 대화형 PKM 인터페이스 — Synology Chat/Telegram 연동)
- Plex, Komga (미디어, PKM과 무관)
4. NAS 파일 구조
/volume4/
├── Document_Server/ ← Synology Drive 전체 관리
│ ├── PKM/ ← FastAPI가 관리하는 영역
│ │ ├── Inbox/ ← 미분류 자료 진입점
│ │ ├── Archive/ ← 이메일, 채팅, 수신 문서 원본
│ │ │ └── imports/ ← 변환 전 교환 형식 원본 (일정 기간 보관)
│ │ ├── Projects/ ← 진행 중 프로젝트 문서
│ │ ├── Knowledge/ ← 도메인별 지식 (v1의 13개 DB 매핑)
│ │ │ ├── Philosophy/
│ │ │ ├── Language/
│ │ │ ├── Engineering/
│ │ │ ├── Industrial_Safety/
│ │ │ │ ├── Legislation/ ← 법령, 고시, 지침
│ │ │ │ ├── Standards/ ← 안전보건 기준
│ │ │ │ └── Cases/ ← 사고 사례, 판례
│ │ │ ├── Programming/
│ │ │ └── General/
│ │ └── Reference/ ← 도면, 참고자료
│ │
│ └── DEVONThink/ ← 기존 WebDAV 폴더 (마이그레이션 후 정리)
│
└── Technicalkorea Document/ ← Synology Drive 동기화 (회사 문서)
└── (회사 문서들) ← PKM이 읽기 전용으로 인덱싱
DEVONthink 13개 DB → 폴더 매핑
| v1 (DEVONthink DB) | v2 (NAS 폴더) |
|---|---|
| Inbox | PKM/Inbox/ |
| Archive | PKM/Archive/ |
| Projects | PKM/Projects/ |
| 00_Note_BOX | PKM/Knowledge/ (루트의 노트 파일) |
| 01_Philosophie | PKM/Knowledge/Philosophy/ |
| 02_Language | PKM/Knowledge/Language/ |
| 03_Engineering | PKM/Knowledge/Engineering/ |
| 04_Industrial safety | PKM/Knowledge/Industrial_Safety/ |
| 05_Programming | PKM/Knowledge/Programming/ |
| 07_General Book | PKM/Knowledge/General/ |
| 97_Production drawing | PKM/Reference/ |
| 99_Reference Data | PKM/Reference/ |
| 99_Technicalkorea | /volume4/Technicalkorea Document/ (별도 경로) |
5. 문서 생애주기 및 "원본" 정의
문서 분류 체계
모든 문서는 성격에 따라 세 가지로 나뉜다:
| 유형 | 정의 | 원본 = | 예시 |
|---|---|---|---|
| immutable | 수신/생성 후 변경하지 않는 문서 | 수신한 그 파일 | PDF, 수신 HWP, 법령, 스캔 이미지, 계약서 |
| editable | 내가 편집하는 살아있는 문서 | Synology Office 포맷 (.odoc, .osheet) | 작성 중인 보고서, 스프레드시트 |
| note | 직접 작성하는 텍스트 | Markdown 파일 | 노트, 메모, 일지 |
교환 형식 처리 원칙
.docx, .xlsx, .pptx는 교환 형식이지, 원본이 아니다.
- 수신 시: 교환 형식으로 들어오면 → Synology Office 포맷으로 변환 (이것이 원본이 됨) → 교환 형식 원본은
Archive/imports/에 일정 기간 보관 후 정리 - 발신 시: Synology Office 원본 →
.docx/.xlsx로 내보내기 → 임시 파일로 생성, 다운로드 후 자동 삭제 - 예외: 외부에서 받은 문서를 편집 없이 보관만 해야 하는 경우 (예: 계약서 원본) →
immutable로 분류, 원본 형식 그대로 보관
데이터 3계층
┌─────────────────────────────────────────────────────────┐
│ 1계층: 원본 파일 (NAS) │
│ 위치: /volume4/Document_Server/PKM/ │
│ 내용: 사용자가 보고 편집하는 실제 파일 │
│ 관리: Synology Drive (버전 이력 자동) │
│ 원칙: 포맷별 단일 원본만 존재 │
└──────────────────────────┬──────────────────────────────┘
│ 텍스트 추출 (kordoc 등)
┌──────────────────────────▼──────────────────────────────┐
│ 2계층: 가공 데이터 (PostgreSQL) │
│ 내용: │
│ - extracted_text (Markdown 변환 텍스트) │
│ - AI 메타데이터 (태그, 요약, 분류) │
│ - 전문검색 인덱스 (GIN + pg_trgm) │
│ 원칙: 원본에서 파생, 언제든 재생성 가능 │
│ 버전 추적: extractor_version, ai_model_version │
└──────────────────────────┬──────────────────────────────┘
│ 벡터 변환 (GPU 서버)
┌──────────────────────────▼──────────────────────────────┐
│ 3계층: 파생물 (pgvector + 캐시) │
│ 내용: │
│ - 벡터 임베딩 (vector(768)) │
│ - 미리보기 캐시 (Markdown 렌더링 결과) │
│ - 썸네일 (이미지/PDF 첫 페이지) │
│ 원칙: 2계층에서 파생, 캐시 성격, 삭제해도 무관 │
└─────────────────────────────────────────────────────────┘
핵심 원칙: 1계층(NAS)이 삭제되면 복구 불가. 2~3계층은 1계층에서 언제든 재생성 가능. 따라서 백업 우선순위는 1계층 > 2계층 >> 3계층.
6. 데이터 모델 (PostgreSQL 스키마)
documents 테이블
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TYPE doc_type AS ENUM ('immutable', 'editable', 'note');
CREATE TYPE source_channel AS ENUM (
'law_monitor', 'devonagent', 'email', 'web_clip',
'tksafety', 'inbox_route', 'manual', 'drive_sync'
);
CREATE TYPE data_origin AS ENUM ('work', 'external');
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
-- 1계층: 원본 파일 참조
file_path TEXT NOT NULL UNIQUE, -- NAS 상의 원본 경로
file_hash CHAR(64) NOT NULL, -- SHA-256 (변경 감지 + 중복 방지)
file_format VARCHAR(20) NOT NULL, -- hwp, pdf, odoc, osheet, md, ...
file_size BIGINT,
file_type doc_type NOT NULL DEFAULT 'immutable',
import_source TEXT, -- 교환 형식 유입 기록 (nullable)
-- 예: "email:msg-id-xxx, 원본:보고서.docx"
-- 2계층: 텍스트 추출
extracted_text TEXT, -- Markdown 변환 전문
extracted_at TIMESTAMPTZ,
extractor_version VARCHAR(50), -- "kordoc@1.7.1"
-- 2계층: AI 가공
ai_summary TEXT, -- AI 요약 (500자 이내)
ai_tags JSONB DEFAULT '[]', -- ["산업안전", "법령개정", ...]
ai_domain VARCHAR(100), -- Knowledge/Industrial_Safety
ai_sub_group VARCHAR(100), -- Legislation
ai_model_version VARCHAR(50), -- "qwen3.5-35b-a3b"
ai_processed_at TIMESTAMPTZ,
-- 3계층: 벡터 임베딩
embedding vector(768), -- nomic-embed-text 출력
embed_model_version VARCHAR(50), -- "nomic-embed-text-v1.5"
embedded_at TIMESTAMPTZ,
-- 메타데이터
source_channel source_channel,
data_origin data_origin,
title TEXT, -- 문서 제목 (AI 추출 또는 파일명)
-- 타임스탬프
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 전문검색 인덱스
CREATE INDEX idx_documents_fts ON documents
USING GIN (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(extracted_text, '')));
-- 트리그램 인덱스 (한국어 부분 매칭)
CREATE INDEX idx_documents_trgm ON documents
USING GIN ((coalesce(title, '') || ' ' || coalesce(extracted_text, '')) gin_trgm_ops);
-- 벡터 유사도 검색 인덱스
CREATE INDEX idx_documents_embedding ON documents
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- 해시 기반 중복 검색
CREATE INDEX idx_documents_hash ON documents (file_hash);
-- 재가공 대상 필터링
CREATE INDEX idx_documents_ai_version ON documents (ai_model_version);
CREATE INDEX idx_documents_extractor_version ON documents (extractor_version);
CREATE INDEX idx_documents_embed_version ON documents (embed_model_version);
tasks 테이블 (CalDAV 캐시)
-- Synology Calendar가 원본, 이 테이블은 웹 UI 표시용 캐시
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
caldav_uid TEXT UNIQUE, -- CalDAV VTODO UID
title TEXT NOT NULL,
description TEXT,
due_date TIMESTAMPTZ,
priority SMALLINT DEFAULT 0, -- 0=없음, 1=높음, 5=중간, 9=낮음
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMPTZ,
document_id BIGINT REFERENCES documents(id), -- 관련 문서 연결
source VARCHAR(50), -- 'law_change', 'inbox_overflow', 'manual'
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
processing_queue 테이블 (비동기 가공 큐)
CREATE TYPE process_stage AS ENUM ('extract', 'classify', 'embed');
CREATE TYPE process_status AS ENUM ('pending', 'processing', 'completed', 'failed');
CREATE TABLE processing_queue (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT REFERENCES documents(id) NOT NULL,
stage process_stage NOT NULL,
status process_status DEFAULT 'pending',
attempts SMALLINT DEFAULT 0,
max_attempts SMALLINT DEFAULT 3,
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (document_id, stage, status) -- 같은 단계 중복 방지
);
CREATE INDEX idx_queue_pending ON processing_queue (stage, status)
WHERE status = 'pending';
7. 문서 가공 파이프라인
전체 흐름
파일 유입
│ (이메일 첨부 / 웹 클립 / 법령 모니터 / 직접 업로드 / Drive 동기화)
│
▼
① NAS에 원본 저장 + DB에 메타데이터 등록
│ file_path, file_hash, file_format, source_channel
│ file_hash로 중복 검사 → 이미 있으면 가공 스킵
│
▼
② kordoc-service로 텍스트 추출 (비동기)
│ HWP/HWPX/PDF → Markdown
│ Synology Office 포맷 → Synology API로 텍스트 추출
│ → DB.extracted_text 저장
│ → DB.extractor_version 기록
│
▼
③ MLX로 AI 분류/태그/요약 (비동기)
│ extracted_text → Qwen3.5-35B-A3B
│ → DB.ai_summary, ai_tags, ai_domain, ai_sub_group 저장
│ → DB.ai_model_version 기록
│ → 결과에 따라 NAS 폴더 이동 (Inbox → Knowledge/...)
│
▼
④ GPU 서버로 벡터 임베딩 (비동기)
│ extracted_text → nomic-embed-text
│ → DB.embedding 저장
│ → DB.embed_model_version 기록
│
▼
⑤ 관련 태스크 생성 (조건부)
법령 변경 → CalDAV VTODO 생성 + MailPlus 알림
Inbox 적체(>3건) → 태스크 생성
재가공 전략
모델 업그레이드 시 전체를 다시 돌리지 않는다:
-- kordoc 업그레이드 시: 이전 버전으로 추출된 문서만 재처리
INSERT INTO processing_queue (document_id, stage)
SELECT id, 'extract' FROM documents
WHERE extractor_version != 'kordoc@2.0.0'
AND extracted_text IS NOT NULL;
-- AI 모델 변경 시: 텍스트 재추출 없이 분류만 재실행
INSERT INTO processing_queue (document_id, stage)
SELECT id, 'classify' FROM documents
WHERE ai_model_version != 'qwen3.5-35b-a3b-v2'
AND extracted_text IS NOT NULL;
-- 임베딩 모델 변경 시: 임베딩만 재생성
INSERT INTO processing_queue (document_id, stage)
SELECT id, 'embed' FROM documents
WHERE embed_model_version != 'nomic-embed-text-v2'
AND extracted_text IS NOT NULL;
파일 변경 감지
편집 가능 문서(editable)는 Synology Office에서 수정될 수 있다. FastAPI의 백그라운드 워커가 주기적으로(또는 Synology Drive webhook으로) 파일 해시를 비교:
file_hash 변경 감지
→ extracted_text 무효화
→ processing_queue에 extract → classify → embed 순서로 등록
→ 파이프라인 재실행
8. kordoc 통합
마이크로서비스 구성
kordoc은 Node.js 기반이므로, FastAPI(Python)와 분리하여 Docker 컨테이너로 운영:
# docker-compose.yml (발췌)
kordoc-service:
build:
context: ./services/kordoc
dockerfile: Dockerfile
ports:
- "3100:3100"
volumes:
- /volume4/Document_Server:/documents:ro # NAS 마운트 (읽기 전용)
environment:
- MAX_FILE_SIZE=500MB
- TIMEOUT=30000
API 엔드포인트 (kordoc-service 내부)
POST /parse
body: { filePath: string } 또는 multipart/form-data
response: { markdown: string, metadata: object, format: string }
POST /compare
body: { filePathA: string, filePathB: string }
response: { diffs: BlockDiff[] }
POST /to-hwpx
body: { markdown: string }
response: Buffer (HWPX 파일)
지원 포맷별 처리
| 포맷 | 엔진 | 처리 방식 |
|---|---|---|
| HWP 5.x | kordoc (OLE2 + CFB) | 21종 제어문자 처리, zlib 압축해제 |
| HWPX | kordoc (ZIP + XML) | 매니페스트 파싱, 병합 셀, 중첩 테이블 |
| kordoc (pdfjs-dist) | 라인 그룹핑, 테이블 감지, 머리글/바닥글 필터링 | |
| .odoc/.osheet | Synology Office API | 텍스트 추출 후 Markdown 변환 |
| Markdown | 직접 읽기 | 추가 변환 불필요 |
| 이미지/도면 | GPU 서버 (Qwen2.5-VL-7B) | OCR → 텍스트 추출 |
9. UI 구성
3-패널 레이아웃
┌─────────────────┬───────────────────────────────────────┐
│ │ │
│ 사이드바 (280px) │ 문서 뷰어 (가변) │
│ │ │
│ ┌─────────────┐│ 포맷별 렌더링: │
│ │ 🔍 검색 ││ PDF → pdf.js 내장 뷰어 │
│ └─────────────┘│ 검색어 하이라이팅, 페이지 점프 │
│ │ DOCX등 → Synology Office iframe │
│ ┌─────────────┐│ 편집 가능한 실시간 미리보기 │
│ │ 📥 Inbox(3) ││ HWP → kordoc Markdown 렌더링 │
│ │ 📁 폴더 탐색 ││ 테이블/이미지 포함 │
│ │ 🏷️ 태그 트리 ││ MD → 렌더링 뷰 + 편집 토글 │
│ │ 🕐 최근 문서 ││ 코드 하이라이팅, KaTeX, Mermaid│
│ │ ⭐ 즐겨찾기 ││ 이미지 → 직접 렌더링 │
│ └─────────────┘│ │
│ ├───────────────────────────────────────┤
│ │ 메타데이터 패널 │
│ ┌─────────────┐│ │
│ │ 📋 오늘 할일 ││ 태그 | 분류 | AI 요약 │
│ │ 📊 통계 ││ 관련 문서 5건 (pgvector 유사도) │
│ └─────────────┘│ 유입 경로 | 원본 형식 | 가공 이력 │
│ │ [Synology에서 열기] [내보내기] [편집] │
└─────────────────┴───────────────────────────────────────┘
DEVONthink 대비 개선 포인트
| 기능 | DEVONthink | PKM 웹앱 |
|---|---|---|
| HWP 미리보기 | Quick Look (불안정) | kordoc → Markdown 렌더링 (테이블/이미지 포함) |
| Office 편집 | 외부 앱 실행 필요 | Synology Office iframe 내 편집 |
| AI 요약 | 없음 | 문서 열 때 자동 표시 |
| 관련 문서 | See Also (불투명한 알고리즘) | pgvector 코사인 유사도 (투명, 조정 가능) |
| 검색 하이라이팅 | 제한적 | pdf.js / Markdown 렌더러에서 정확한 위치 점프 |
| 법령 비교 | 없음 | kordoc compare → 조항별 diff 표시 |
| 접근성 | macOS만 | 웹 브라우저 어디서든 |
| 모바일 | DEVONthink To Go (유료) | 반응형 웹 UI |
대시보드 위젯
로그인 시 첫 화면:
- 오늘의 태스크: Synology Calendar에서 CalDAV로 가져온 VTODO 목록
- Inbox 미분류: 분류 대기 중인 문서 수 + 빠른 분류 UI
- 법령 변경 알림: 최근 감지된 법령 변경사항
- 최근 문서: 최근 추가/수정된 문서 5건
- 시스템 상태: 각 서비스 가동 상태 (FastAPI, kordoc, MLX, GPU, NAS)
10. 인증 및 외부 접근
구조
인터넷 → Caddy (Mac mini, 자동 HTTPS)
│
├── pkm.도메인.net → FastAPI (JWT 인증 필요)
├── office.도메인.net → Synology Office (프록시)
└── cal.도메인.net → Synology Calendar (프록시)
인증 방식: JWT + TOTP 2FA
1인 사용 시스템이므로 경량하게 구현:
- 로그인: 아이디/비밀번호 → TOTP 2단계 인증 → JWT 발급
- JWT: Access Token (15분) + Refresh Token (7일)
- 비밀번호: bcrypt 해싱, DB 저장
- TOTP: Vaultwarden에 시드 저장, 앱(Google Authenticator 등)으로 생성
- Rate Limiting: 로그인 시도 5회 실패 시 15분 잠금
Caddy 설정 예시
document.hyungi.net {
reverse_proxy localhost:8000 # FastAPI
}
# Synology Office는 NAS로 프록시
office.hyungi.net {
reverse_proxy https://ds1525.hyungi.net:5001 {
header_up Host {upstream_hostport}
}
}
11. AI Gateway 및 모델 전략
설계 원칙: Qwen3.5 35B 우선, 외부 API는 최후 수단
모든 자동화 파이프라인은 로컬 모델(Qwen3.5-35B-A3B)을 기본으로 사용한다. Claude API(종량제)는 로컬 모델로 처리 불가능한 복잡한 분석에만 제한적으로 사용한다. 이를 통해 운영 비용을 0원에 가깝게 유지하면서, 필요시에만 외부 API 비용이 발생하는 구조를 만든다.
참고: Claude 구독 vs API 인증 Anthropic은 2026년 1월부터 구독(Pro/Max) OAuth 토큰의 서드파티 사용을 약관상 금지했다. 자동화 파이프라인에서 Claude를 사용하려면 반드시 **API 키(종량제)**를 사용해야 한다. 개인 실험 목적의 NanoClaw 등은 현재 작동하지만 회색 지대이므로, PKM 핵심 파이프라인은 이에 의존하지 않는다.
AI Gateway (GPU 서버)
GPU 서버에 AI 중계 서버(Gateway)를 두어, 모든 AI 요청을 단일 진입점으로 관리한다. FastAPI에서 직접 각 모델 엔드포인트를 호출하는 대신, Gateway가 라우팅/폴백/로깅을 담당한다.
FastAPI (Mac mini)
│
▼
AI Gateway (GPU 서버, OpenAI 호환 API)
│
├── /v1/chat/completions (텍스트 생성)
│ ├── 1순위: Mac mini MLX → Qwen3.5-35B-A3B (상시, 무료)
│ ├── 2순위: GPU Ollama → Qwen3.5 또는 동급 모델 (MLX 장애 시 폴백)
│ └── 3순위: Claude API → claude-sonnet (복잡한 분석 전용, 종량제)
│
├── /v1/embeddings (벡터 임베딩)
│ └── GPU Ollama → nomic-embed-text (로컬, 무료)
│
├── /v1/vision (OCR/비전)
│ └── GPU Ollama → Qwen2.5-VL-7B (로컬, 무료)
│
└── /v1/rerank (리랭킹)
└── GPU Ollama → bge-reranker-v2-m3 (로컬, 무료)
모델 라우팅 규칙
| 작업 | 모델 | 위치 | 비용 | 비고 |
|---|---|---|---|---|
| 문서 분류/태그 | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | 기본값, 전체 처리량의 95%+ |
| 문서 요약 (4,000자 이내) | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | 대부분의 문서 커버 |
| 문서 요약 (장문/다국어) | Claude Sonnet | API | 종량제 | 로컬 모델 한계 초과 시만 |
| 법령 비교/분석 | Qwen3.5-35B-A3B | Mac mini MLX | 무료 | 우선 시도, 품질 미달 시 Claude |
| 벡터 임베딩 | nomic-embed-text | GPU 서버 | 무료 | GPU 전용 |
| 이미지/도면 OCR | Qwen2.5-VL-7B | GPU 서버 | 무료 | GPU 전용 |
| RAG 리랭킹 | bge-reranker-v2-m3 | GPU 서버 | 무료 | GPU 전용 |
| 복잡한 분석 (장문, 전문가 수준) | Claude Sonnet/Opus | API | 종량제 | 수동 트리거 또는 명시적 요청 |
폴백 체인 및 비용 제어
# config.yaml
ai:
gateway:
endpoint: "http://gpu-server:8080" # AI Gateway 단일 진입점
models:
primary: # 기본 모델 (자동화 전체)
endpoint: "http://localhost:8800/v1/chat/completions" # Mac mini MLX
model: "mlx-community/Qwen3.5-35B-A3B-4bit"
max_tokens: 4096
timeout: 60
fallback: # MLX 장애 시 폴백
endpoint: "http://gpu-server:11434/v1/chat/completions" # GPU Ollama
model: "qwen3.5:35b-a3b"
max_tokens: 4096
timeout: 120
premium: # 복잡한 분석 전용 (수동 트리거)
endpoint: "https://api.anthropic.com/v1/messages"
model: "claude-sonnet-4-20250514"
api_key: "${CLAUDE_API_KEY}"
max_tokens: 8192
# 비용 제어
daily_budget_usd: 5.00 # 일일 한도
require_explicit_trigger: true # 자동 파이프라인에서 자동 호출 금지
embedding:
endpoint: "http://gpu-server:11434/api/embeddings"
model: "nomic-embed-text"
vision:
endpoint: "http://gpu-server:11434/api/generate"
model: "Qwen2.5-VL-7B"
rerank:
endpoint: "http://gpu-server:11434/api/rerank"
model: "bge-reranker-v2-m3"
AI Gateway 구현
GPU 서버에 경량 프록시 서비스를 Docker로 운영:
# GPU 서버 docker-compose.yml
ai-gateway:
build: ./services/ai-gateway
ports:
- "8080:8080"
environment:
- PRIMARY_ENDPOINT=http://mac-mini:8800/v1/chat/completions
- FALLBACK_ENDPOINT=http://localhost:11434/v1/chat/completions
- CLAUDE_API_KEY=${CLAUDE_API_KEY}
- DAILY_BUDGET_USD=5.00
restart: unless-stopped
Gateway의 핵심 기능:
- 자동 폴백: Primary(MLX) 실패/타임아웃 시 Fallback(GPU Ollama)으로 전환
- 비용 제어: Claude API 호출 시 일일 예산 확인, 초과 시 거부
- 요청 로깅: 모든 AI 요청의 모델/토큰수/응답시간/비용 기록
- 헬스체크: 각 모델 엔드포인트의 가용성 주기적 확인
- 라우팅 태그: 요청에
x-model-tier: primary|fallback|premium태그로 명시적 라우팅 가능
AI 추상화 레이어 (FastAPI 측)
# ai/client.py — 통합 클라이언트
class AIClient:
"""AI Gateway를 통한 통합 클라이언트. 기본값은 항상 Qwen3.5."""
def __init__(self, config: dict):
self.gateway = config["gateway"]["endpoint"]
self.models = config["models"]
async def classify(self, text: str) -> dict:
"""문서 분류 — 항상 primary(Qwen3.5) 사용"""
return await self._call(self.models["primary"], prompt=CLASSIFY_PROMPT.format(text=text))
async def summarize(self, text: str, force_premium: bool = False) -> str:
"""문서 요약 — 기본 Qwen3.5, 장문이거나 명시적 요청 시만 Claude"""
if force_premium or len(text) > 15000:
return await self._call(self.models["premium"], prompt=SUMMARIZE_PROMPT.format(text=text))
return await self._call(self.models["primary"], prompt=SUMMARIZE_PROMPT.format(text=text))
async def embed(self, text: str) -> list[float]:
"""벡터 임베딩 — GPU 서버 전용"""
return await self._call_embed(self.models["embedding"], text=text)
async def ocr(self, image_bytes: bytes) -> str:
"""이미지 OCR — GPU 서버 전용"""
return await self._call_vision(self.models["vision"], image=image_bytes)
async def _call(self, model_config: dict, prompt: str) -> str:
"""OpenAI 호환 API 호출 + 자동 폴백"""
try:
return await self._request(model_config, prompt)
except (TimeoutError, ConnectionError):
# Primary 실패 시 Fallback 시도
if model_config == self.models["primary"]:
return await self._request(self.models["fallback"], prompt)
raise
NanoClaw 통합 (선택적 확장)
NanoClaw는 PKM 핵심 파이프라인이 아닌, 대화형 인터페이스로서 선택적으로 통합:
NanoClaw (GPU 서버, Docker)
│
├── Synology Chat / Telegram 연동
│ → "지난주 법령 변경사항 알려줘" → PKM API 호출 → 답변
│ → "이 문서 분류해줘" (파일 전송) → 가공 파이프라인 트리거
│
├── 인증: Claude API 키 (종량제) — 구독 OAuth 의존 안 함
│
└── 스케줄링: 일일 다이제스트를 채팅으로 전송 (MailPlus 보완)
NanoClaw의 ANTHROPIC_BASE_URL을 AI Gateway로 설정하면,
NanoClaw도 동일한 라우팅 규칙(Qwen3.5 우선)을 따르게 된다:
# NanoClaw .env
ANTHROPIC_BASE_URL=http://localhost:8080/v1 # AI Gateway
ANTHROPIC_AUTH_TOKEN=${CLAUDE_API_KEY} # API 키 (구독 OAuth 아님)
장기 전환 시 변경 사항
| 전환 | config.yaml 변경 | 코드 수정 |
|---|---|---|
| MLX → GPU vLLM (Mac mini 퇴역) | primary.endpoint URL 변경 | 없음 |
| Qwen3.5 → 더 좋은 모델 | primary.model 변경 | 없음 |
| GPU 확장 (RTX Pro 6000) | 더 큰 모델 추가, 라우팅 규칙 조정 | 없음 |
| Claude API 불필요해질 때 | premium 섹션 제거 | 없음 |
| AI Gateway를 NAS로 이전 | gateway.endpoint URL 변경 | 없음 |
12. Synology 서비스 연동
Synology Calendar (CalDAV)
OmniFocus를 대체하는 태스크 관리:
# caldav 라이브러리로 연동
import caldav
client = caldav.DAVClient(
url="https://ds1525.hyungi.net/caldav/",
username="hyungi",
password=CALDAV_PASSWORD
)
# 태스크 생성 (법령 변경 감지 시)
calendar = client.calendar(name="PKM")
calendar.save_todo(
summary="[법령검토] 산업안전보건법 시행규칙 개정안 확인",
description="2026-04-01 관보 게재. 변경 내용 검토 필요.",
priority=1, # 높음
due=datetime.now() + timedelta(days=3)
)
MailPlus (IMAP 수집 + SMTP 알림)
기존 mailplus_archive.py에서 AppleScript 의존성만 제거:
# 수집: 기존 IMAP 로직 유지 (이미 AppleScript 미사용)
# 알림: SMTP 발송 추가
import smtplib
from email.mime.text import MIMEText
def send_notification(subject: str, body: str):
msg = MIMEText(body, 'html')
msg['Subject'] = subject
msg['From'] = 'pkm@hyungi.net'
msg['To'] = 'hyungi@hyungi.net'
with smtplib.SMTP_SSL(MAILPLUS_HOST, 465) as server:
server.login(MAILPLUS_USER, MAILPLUS_PASS)
server.send_message(msg)
Synology Office (문서 뷰어)
웹앱에서 문서 클릭 시 파일 유형에 따라 분기:
// SvelteKit 프론트엔드
function openDocument(doc: Document) {
switch (doc.file_format) {
case 'odoc':
case 'osheet':
case 'odp':
// Synology Office iframe으로 열기
viewerUrl = `https://office.hyungi.net/oo/r/${doc.synology_file_id}`;
break;
case 'pdf':
// pdf.js 내장 뷰어
viewerUrl = `/viewer/pdf?path=${doc.file_path}`;
break;
case 'hwp':
case 'hwpx':
// kordoc 변환된 Markdown 렌더링
viewerContent = doc.extracted_text;
viewerMode = 'markdown';
break;
case 'md':
// Markdown 렌더링 + 편집 토글
viewerContent = doc.extracted_text;
viewerMode = 'markdown-editable';
break;
default:
// 이미지 직접 렌더링 또는 다운로드
viewerUrl = `/api/files/${doc.id}/raw`;
}
}
13. v1 코드 재활용 계획
재활용 가능 (70%+)
| v1 파일 | v2 용도 | 변경 사항 |
|---|---|---|
scripts/pkm_utils.py |
app/core/utils.py |
run_applescript() 제거, 나머지 유지 |
scripts/law_monitor.py |
app/workers/law_monitor.py |
AppleScript → FastAPI DB 직접 저장 |
scripts/mailplus_archive.py |
app/workers/mailplus_archive.py |
AppleScript → DB 저장 + SMTP 알림 추가 |
scripts/pkm_daily_digest.py |
app/workers/daily_digest.py |
OmniFocus 쿼리 → CalDAV 쿼리, AppleScript → DB 쿼리 |
scripts/prompts/classify_document.txt |
app/prompts/classify.txt |
그대로 사용 |
scripts/embed_to_chroma.py |
app/workers/embed_worker.py |
ChromaDB → pgvector |
폐기
| v1 파일 | 이유 |
|---|---|
applescript/auto_classify.scpt |
AppleScript 전체 폐기 |
applescript/omnifocus_sync.scpt |
OmniFocus → CalDAV로 대체 |
launchd/*.plist |
Docker 내부 스케줄러(APScheduler)로 대체 |
14. 프로젝트 구조 (v2)
pkm-web/
├── docker-compose.yml ← 전체 서비스 정의
├── config.yaml ← AI 엔드포인트, NAS 경로 등
├── credentials.env ← 인증 정보 (.gitignore)
│
├── app/ ← FastAPI 백엔드
│ ├── main.py ← FastAPI 앱 엔트리포인트
│ ├── core/
│ │ ├── config.py ← 설정 로딩
│ │ ├── database.py ← PostgreSQL 연결
│ │ ├── auth.py ← JWT + TOTP 인증
│ │ └── utils.py ← 공통 유틸리티
│ ├── models/
│ │ ├── document.py ← documents 테이블 ORM
│ │ ├── task.py ← tasks 테이블 ORM
│ │ └── queue.py ← processing_queue ORM
│ ├── api/
│ │ ├── documents.py ← 문서 CRUD API
│ │ ├── search.py ← 전문검색 + 벡터검색 API
│ │ ├── tasks.py ← CalDAV 연동 태스크 API
│ │ ├── dashboard.py ← 대시보드 데이터 API
│ │ └── export.py ← 내보내기 (Markdown → DOCX/HWPX)
│ ├── workers/
│ │ ├── file_watcher.py ← NAS 파일 변경 감지
│ │ ├── extract_worker.py ← kordoc 호출, 텍스트 추출
│ │ ├── classify_worker.py ← AI 분류/태그/요약
│ │ ├── embed_worker.py ← 벡터 임베딩
│ │ ├── law_monitor.py ← 법령 변경 모니터링
│ │ ├── mailplus_archive.py ← 이메일 수집
│ │ └── daily_digest.py ← 일일 다이제스트
│ ├── prompts/
│ │ └── classify.txt ← AI 분류 프롬프트
│ └── ai/
│ └── client.py ← AI 추상화 레이어
│
├── services/
│ └── kordoc/
│ ├── Dockerfile ← kordoc Node.js 컨테이너
│ ├── package.json
│ └── server.js ← HTTP API 래퍼
│
├── frontend/ ← SvelteKit 프론트엔드
│ ├── src/
│ │ ├── routes/
│ │ │ ├── +page.svelte ← 대시보드 (메인)
│ │ │ ├── documents/ ← 문서 탐색/검색/뷰어
│ │ │ ├── inbox/ ← Inbox 분류 UI
│ │ │ └── settings/ ← 설정
│ │ ├── lib/
│ │ │ ├── components/ ← 공통 컴포넌트
│ │ │ │ ├── DocumentViewer.svelte
│ │ │ │ ├── MetadataPanel.svelte
│ │ │ │ ├── Sidebar.svelte
│ │ │ │ └── TaskWidget.svelte
│ │ │ └── stores/ ← 상태 관리
│ │ └── app.html
│ └── package.json
│
├── migrations/ ← PostgreSQL 마이그레이션
│ └── 001_initial_schema.sql
│
├── scripts/
│ └── migrate_from_devonthink.py ← v1 → v2 마이그레이션 스크립트
│
├── docs/
│ ├── architecture.md ← 이 문서
│ └── deploy.md ← 배포 가이드
│
└── tests/
├── test_api.py
├── test_classify.py
└── test_pipeline.py
15. 마이그레이션 전략 (v1 → v2)
단계별 전환
기존 DEVONthink 시스템을 운영하면서 점진적으로 전환:
Phase 0: 기반 구축 (1~2주)
- Docker Compose 구성 (FastAPI + PostgreSQL + kordoc + Caddy)
- DB 스키마 생성
- 인증 시스템 구현
- NAS SMB 마운트 확인
Phase 1: 데이터 마이그레이션 (1~2주)
- DEVONthink 13개 DB에서 파일 목록 + 메타데이터 추출
- NAS의
Document_Server/PKM/폴더 구조 생성 - 파일 복사 + DB 등록 스크립트 (
migrate_from_devonthink.py) - kordoc으로 전문 텍스트 추출 (배치)
- AI 분류 재실행 (배치)
- 벡터 임베딩 생성 (배치)
Phase 2: 핵심 기능 구현 (2~3주)
- 문서 검색 API + UI
- 문서 뷰어 (pdf.js, Markdown, Synology Office)
- Inbox 자동 분류 파이프라인
- 파일 변경 감지 + 재가공
Phase 3: 자동화 이전 (1~2주)
- law_monitor → FastAPI 워커로 이전
- mailplus_archive → FastAPI 워커로 이전
- daily_digest → FastAPI 워커로 이전 + MailPlus 알림
- CalDAV 태스크 연동
Phase 4: UI 완성 + 최적화 (2~3주)
- 대시보드 위젯
- 태그/폴더 탐색 UI
- 메타데이터 패널 (AI 요약, 관련 문서)
- 반응형 모바일 대응
- 성능 최적화 (캐싱, 인덱스 튜닝)
Phase 5: DEVONthink 퇴역
- 2주간 병행 운영 후 DEVONthink 완전 종료
- NAS의
Document_Server/DEVONThink/폴더 아카이브 후 정리
예상 소요: 약 8~12주
16. 장기 로드맵
서버 전환 계획
현재 (2026):
Mac mini → Docker (FastAPI, PostgreSQL, kordoc, Caddy) + MLX + Roon Core
GPU 서버 → 임베딩, OCR, 리랭킹 + Plex, Komga
Synology → 파일 저장, Office, Calendar, MailPlus, Drive
중기 (GPU 서버 확장 시):
Mac mini → Roon Core 전용
GPU 서버 → Docker 전체 이전 + AI 추론 통합 (vLLM/Ollama CUDA)
config.yaml의 endpoint URL만 변경
Synology → 변동 없음
장기 (Mac mini 퇴역 시):
GPU 서버 → 모든 컴퓨팅 (또는 Roon도 Linux 지원 시 통합)
Synology → 변동 없음 (교체 시에도 Synology 유지)
궁극 (Synology GPU 지원 시):
Synology 단일 서버 가능성 (파일 + 컴퓨팅 + AI 통합)
전환 시 변경 사항
| 전환 | 변경 | 코드 수정 |
|---|---|---|
| Mac mini → GPU 서버 | docker-compose.yml 이동, config.yaml 수정 | 없음 |
| MLX → vLLM/Ollama | config.yaml의 endpoint URL 변경 | 없음 |
| GPU 확장 (RTX Pro 6000) | 더 큰 모델 사용 가능, config.yaml만 변경 | 없음 |
| Synology 교체 | NAS IP/도메인 변경, credentials.env 수정 | 없음 |
17. 자동화 스케줄
Docker 내부에서 APScheduler로 관리 (launchd 대체):
| 시간 | 작업 | 주기 |
|---|---|---|
| 07:00 | law_monitor | 매일 |
| 07:00, 18:00 | mailplus_archive | 매일 2회 |
| 20:00 | daily_digest | 매일 |
| */5분 | file_watcher | 상시 (NAS 변경 감지) |
| */10분 | processing_queue consumer | 상시 (가공 큐 처리) |
부록 A: credentials.env 템플릿
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=pkm
POSTGRES_USER=pkm
POSTGRES_PASSWORD=
# AI
MLX_ENDPOINT=http://localhost:8800/v1/chat/completions
MLX_MODEL=mlx-community/Qwen3.5-35B-A3B-4bit
GPU_SERVER_IP=
GPU_EMBED_PORT=11434
CLAUDE_API_KEY=
# Synology NAS
NAS_SMB_PATH=/Volumes/Document_Server # macOS SMB 마운트 경로
NAS_DOMAIN=ds1525.hyungi.net
NAS_TAILSCALE_IP=100.101.79.37
NAS_PORT=15001
# Synology MailPlus
MAILPLUS_HOST=mailplus.hyungi.net
MAILPLUS_PORT=993
MAILPLUS_SMTP_PORT=465
MAILPLUS_USER=hyungi
MAILPLUS_PASS=
# Synology Calendar (CalDAV)
CALDAV_URL=https://ds1525.hyungi.net/caldav/
CALDAV_USER=hyungi
CALDAV_PASS=
# kordoc
KORDOC_ENDPOINT=http://kordoc-service:3100
# 인증
JWT_SECRET=
TOTP_SECRET=
# 법령 API
LAW_OC=
부록 B: docker-compose.yml 골격
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg16
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
environment:
POSTGRES_DB: pkm
POSTGRES_USER: pkm
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
restart: unless-stopped
kordoc-service:
build: ./services/kordoc
ports:
- "3100:3100"
volumes:
- ${NAS_SMB_PATH}:/documents:ro
restart: unless-stopped
fastapi:
build: ./app
ports:
- "8000:8000"
volumes:
- ${NAS_SMB_PATH}:/documents
depends_on:
- postgres
- kordoc-service
env_file:
- credentials.env
restart: unless-stopped
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- fastapi
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
depends_on:
- fastapi
- frontend
restart: unless-stopped
volumes:
pgdata:
caddy_data: