Files
hyungi_document_server/docs/architecture.md
Hyungi Ahn 0ca78640ee infra: migrate application from Mac mini to GPU server
- 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>
2026-04-03 07:47:09 +09:00

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 관리의 복잡성, 웹 접근 불가, 모바일 접근 제한.

설계 원칙

  1. macOS 의존성 제거 — AppleScript, DEVONthink, OmniFocus 전부 탈피
  2. 컨테이너 기반 — 모든 서비스를 Docker로. 서버 간 이동이 docker compose up 한 번
  3. AI 추상화 — OpenAI 호환 API 인터페이스로 통일. MLX/vLLM/Ollama 무관
  4. Synology 서비스 활용 극대화 — 직접 만들지 않아도 되는 것은 만들지 않는다
  5. 원본 단일 원칙 — 같은 정보가 두 곳에 존재하지 않는다

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) 매니페스트 파싱, 병합 셀, 중첩 테이블
PDF 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: