[Homelab] OneNote 노트를 SilverBullet으로 옮기고 pgvector RAG 검색 붙이기


저는 오랫동안 리눅스, 서버, 홈랩 관련 메모를 전부 OneNote에 적어왔습니다. 노트북이 열 개 넘게, 페이지가 2천 개 넘게 쌓이고 나니 문제가 생겼습니다. 분명히 예전에 고쳐본 문제인데, “그때 이거 어떻게 했더라?” 가 검색이 안 되는 겁니다. 키워드가 정확히 맞아야 찾을 수 있고, 스크린샷 속 글자는 아예 안 잡히고요.

그래서 이번에 큰맘 먹고 두 가지를 했습니다.

  1. OneNote의 노트를 평범한 Markdown 파일로 꺼내서 셀프호스팅 위키인 SilverBullet으로 옮기기
  2. 그 Markdown 위에 pgvector 기반 RAG 검색을 붙여서, 자연어로 물어보면 내 노트에서 답을 찾아오게 만들기

9월 초에 시작해서 10월 7일에 일단락됐으니 한 달 정도 걸렸네요. ㅎㅎ

🗺️ 전체 구조

OneNote
  │  Microsoft Graph API (Notes.Read, 읽기 전용)
  ▼
HTML 아카이브 (원본 HTML + 배치 좌표 + 이미지 + 첨부)
  │  자체 변환기
  ▼
Markdown ──► SilverBullet (notes.cola16.app)
  │           CephFS 위의 같은 폴더를 SMB, Nextcloud에서도 접근
  │
  │  15분마다 증분 인덱서 (k8s CronJob)
  ▼
LiteLLM → OpenRouter (Qwen3-Embedding-8B, 1536차원)
  ▼
CNPG PostgreSQL + pgvector
  ▼
검색 MCP 서버 ──► Open WebUI (채팅에서 "내 노트 검색" 도구로)
검색 웹       ──► rag.cola16.app

전부 쿠버네티스 위에 Fleet(GitOps)으로 배포했습니다.

📦 1단계: OneNote에서 “빠짐없이” 꺼내기

OneNote → Markdown 변환 도구로 유명한 건 onenote-md-exporter(Windows용)인데, 저는 Mac에서 작업하고 싶었고, 무엇보다 변환 전에 원본부터 빠짐없이 보존하고 싶었습니다. OneNote는 텍스트 상자를 자유롭게 배치할 수 있어서, Markdown으로 바꾸는 순간 정보가 손실되기 쉽거든요.

그래서 macOS + PowerShell 7 + Microsoft Graph로 직접 exporter를 만들었습니다. (GitHub: colaH16/onenote-md-exporter)

  • 권한은 읽기 전용 Notes.Read 하나. Azure 구독이나 Entra 앱 등록도 필요 없습니다. (Graph PowerShell SDK 기본 앱으로 로그인)
  • 1단계는 변환이 아니라 보존입니다. 페이지별 Graph HTML, 텍스트 상자의 top/left/width/height 좌표, 이미지, 첨부파일, 섹션/페이지 계층을 전부 로컬에 저장합니다.
  • 실패한 리소스는 repair 명령으로 다시 받습니다. 불완전 페이지 59개 중 57개를 자동으로 복구했고, 나머지는 손으로 채웠습니다.

보존 계층은 세 단계로 나눴습니다.

계층내용용도
네이티브 백업OneNote 웹에서 받은 ZIP/.one재해 복구
이관 원본exporter가 만든 HTML, 리소스, manifest변환의 기준
활용본검증된 원본으로 만든 MarkdownSilverBullet, RAG

그리고 하나 정한 원칙: 모든 노트북을 옮기진 않는다. 아주 개인적인 노트는 OneNote에 그대로 두고 HTML 백업만 합니다. 변환 대상은 허용 목록에 노트북 ID를 명시한 것만(기본 거부, default deny) 처리하고, 백업용 목록을 변환용으로 재사용하지 않도록 파일 자체를 분리했습니다. 나중에 RAG 인덱서도 허용 목록 밖 노트북 ID가 하나라도 보이면 전체 작업을 중단합니다.

🔧 2단계: 변환이 생각보다 어려웠던 이유

HTML → Markdown 자체는 쉬운데, “내가 OneNote를 쓰던 습관” 을 해석하는 게 어려웠습니다.

  • 1×1 표 = 코드 블록: 예전엔 OneNote에 코드 블록이 없어서 셀 하나짜리 표에 명령어를 넣었습니다. 이런 표는 복사 가능한 fenced code block으로 바꿨고, 여러 칸짜리 표라도 셸 명령이 여러 줄 들어 있으면 셀별 코드 블록으로 펼쳤습니다.
  • 자유 배치: 옆에 붙여 둔 짧은 메모가 “앞 블록에 대한 주석”인지 별개 내용인지는 기계가 판단할 수 없습니다. 이런 페이지 386개는 needs_visual_review: true로 표시하고, 직접 검토한 43개는 좌표 기반 교정 규칙으로 재구성했습니다. 원본 좌표가 사라지거나 처리 안 된 박스가 생기면 변환을 실패시켜서, 원문이 바뀌었는데 옛 교정을 몰래 적용하는 일이 없게 했습니다.
  • --로 시작하는 이름 = 안 쓰는 문서: 제 오래된 습관인데, 이런 페이지는 _old/ 폴더로 옮기고 status: old, rag_priority: fallback을 붙였습니다. 나중에 RAG가 “현재 문서 우선, 옛날 문서는 후순위”로 검색하는 근거가 됩니다.
  • 손글씨와 수식: t/τ, l/1/I, O/0 같은 건 앞뒤 문맥으로 판독하고, 확정 못 하면 추측해서 고치지 않고 검토 필요로 남겼습니다.

결과는 이렇습니다.

항목값
노트북 / 섹션 / 페이지15 / 206 / 2,178
옛 문서 (old, fallback)115
첨부·이미지2,074개, 약 924MB
OneNote 내부 링크 변환 / 미해결177 / 0

(이후 노트북 2개를 더 넣어서 지금은 17개, 2,220페이지입니다.)

📝 3단계: SilverBullet

SilverBullet은 Markdown 파일을 그대로 저장하는 셀프호스팅 노트 앱입니다. DB에 갇히지 않고 그냥 .md 파일이라는 점이 결정적이었습니다.

  • 노트 폴더는 PVC가 아니라 CephFS를 hostPath로 붙였습니다. 이 폴더가 Nextcloud와 SMB에서 쓰는 폴더 안에 있어서, 같은 Markdown 파일을 웹(SilverBullet), Nextcloud, SMB 세 곳에서 볼 수 있습니다.
  • hostPath.type: Directory로 설정해서, CephFS가 안 붙어 있으면 파드가 아예 안 뜹니다. 로컬 디스크에 몰래 쓰는 사고를 막기 위해서입니다.
  • 쓰는 쪽은 SilverBullet 하나뿐이도록 replica 1, strategy: Recreate. 파일 소유자가 SMB/Nextcloud 쪽과 맞도록 컨테이너 uid/gid도 맞췄습니다.

10월 7일에는 두 가지를 더 했습니다.

  • SilverBullet 2.12 업그레이드: CephFS에서 다른 클라이언트(SMB 등)가 수정한 파일은 inotify 이벤트가 오지 않아서 SB_FS_WATCH=poll(60초)로 바꿨습니다. 2.12의 트리 탐색(Navigate: Tree)도 쓸 만해서 별도 탐색 앱은 안 만들기로 했습니다.
  • OneNote 순서대로 파일명 붙이기: 파일명 순으로 정렬되다 보니 OneNote에서의 페이지 순서가 깨져 있었습니다. 그래서 0010-제목.md, 0020-... 식으로 순번을 붙였습니다. 처음엔 0010.제목을 생각했는데 SilverBullet 2.12가 점 뒤를 확장자로 인식해서 하이픈으로 바꿨습니다. 2,220페이지 이름을 바꾸면서 기존 링크·이미지 연결 4,155개를 전부 유지했고, 링크 표시 문구 3,033개는 그대로 두고 주소만 바꿨습니다. RAG DB도 페이지 ID가 안정적이라 다시 임베딩하지 않고 경로만 갱신했습니다.

🧠 4단계: 임베딩과 벡터 DB

모델: Qwen3-Embedding-8B, 1536차원

처음 계획은 OpenAI text-embedding-3-large였는데, 한국어 + 코드가 섞인 노트라는 점과 가격을 보고 OpenRouter의 qwen/qwen3-embedding-8b로 바꿨습니다. LiteLLM에 별칭으로 등록하고, 요청할 때 ZDR(Zero Data Retention) 공급자만 쓰도록 강제했습니다. 개인 노트를 보내는 거니까요.

차원을 1536으로 줄인 이유는 pgvector 때문입니다. pgvector의 vector 타입 HNSW 인덱스는 2000차원까지만 지원합니다. 그래서 모델에 1536차원을 요청하고 스키마도 vector(1536)으로 잡았습니다.

청킹

  • 모델의 공식 토크나이저를 버전과 SHA-256으로 고정해서 로컬에서 토큰을 셉니다.
  • 목표 650토큰, 일반 상한 800토큰. 각 청크 앞에 페이지 이름과 제목 경로를 붙입니다.
  • 코드 블록과 표는 자르지 않습니다. 명령어가 반으로 잘린 청크는 검색돼도 쓸모가 없으니까요. 그래서 예외적으로 큰 청크가 66개 생겼습니다(최대 5,926토큰). 모델 한도를 넘으면 몰래 자르지 않고 전체 실행을 멈춥니다.
  • 결과: 2,178페이지 → 3,132청크 (현재 문서 2,921 / 옛 문서 211)

DB: CNPG + pgvector

CloudNativePG로 RAG 전용 클러스터(cnpg-vector)를 따로 만들고 pgvector 0.8.1을 올렸습니다. 현재/옛 문서용 HNSW 인덱스를 각각 둡니다.

이 벡터 DB는 일부러 백업하지 않습니다. Markdown 원본만 있으면 언제든 다시 만들 수 있으니까요. 실제로 전체 초기 색인이 약 17분이었습니다. 대신 일반 앱용 DB 클러스터는 S3로 WAL까지 백업합니다.

권한도 나눴습니다. 인덱서 계정은 테이블 쓰기만, 검색 서버 계정은 SELECT만. 둘 다 superuser 같은 특권은 없고, 연결은 TLS verify-full입니다.

🔄 5단계: 15분마다 증분 인덱싱

인덱서는 15분마다 도는 CronJob인데, 신경 쓴 부분이 꽤 많습니다.

  • 바뀐 페이지만 임베딩: 변경이 없으면 임베딩 API를 한 번도 부르지 않습니다. 돈이 나가는 부분이라 중요합니다.
  • 기본은 preview: 명시적으로 apply를 줘야만 API 호출과 DB 쓰기를 합니다. 처음엔 3페이지만 시험으로 넣어보고, 확인한 다음 전체를 넣었습니다.
  • 한 번의 트랜잭션: 실행 단위로 통째로 교체하고, 실패하면 이전 데이터를 그대로 둡니다. advisory lock으로 겹친 실행이 서로 덮어쓰지 않게 했습니다.
  • 이름 바꾸기 ≠ 삭제: 페이지마다 안정적인 ID가 있어서 파일명이 바뀌어도 다시 임베딩하지 않고 경로만 갱신합니다.
  • 삭제는 신중하게: 사라진 페이지는 바로 지우지 않습니다. 두 번 이상 연속으로 안 보이고 15분 유예가 지나야 지웁니다. 그리고 한 번에 20페이지 또는 전체의 2%를 넘게 사라지면 실행 자체를 멈춥니다. CephFS가 잠깐 이상해서 폴더가 비어 보이는 순간 인덱스를 다 날리면 안 되니까요.

🔍 6단계: 검색 — 현재 문서 먼저, 옛 문서는 나중에

검색에서 제일 중요하게 생각한 건 옛날 방식이 현재 방식보다 먼저 나오면 안 된다는 점이었습니다. 3년 전에 시도했다가 버린 설정이 유사도가 더 높다고 맨 위에 오면 곤란하죠.

그래서 유사도에 감점을 주는 대신 검색 단계를 나눴습니다. 1차로 현재 문서만 찾고, 결과가 부족할 때만 옛 문서로 채웁니다. 옛 문서가 쓰이면 “오래된 자료”라고 표시합니다. 결과에는 SilverBullet 원문 링크가 붙어서 바로 원문으로 갈 수 있습니다.

이 검색은 두 군데서 씁니다.

  • MCP 서버: search_notes, get_note_chunk 두 도구를 Streamable HTTP로 제공합니다. Open WebUI(0.11.3)에 MCP 도구로 연결해서, 채팅하다가 “내 노트에서 찾아봐”가 됩니다. 클러스터 내부 전용이고 bearer 토큰 인증이 걸려 있습니다.
  • 검색 웹: rag.cola16.app — 로그인 없이는 공개해도 되는 노트북만, 제가 authentik으로 로그인하면 전체를 검색합니다. 익명 검색은 임베딩 API 비용이 들기 때문에 전체 질의 횟수에 상한을 걸어뒀습니다.

🖼️ 덤: 스크린샷 속 글자까지

OneNote에는 터미널 스크린샷이 정말 많습니다. 이런 이미지는 텍스트 검색에 안 걸리니, 인덱서 앞에 이미지 OCR/설명 생성 단계를 넣었습니다(Gemini, ZDR). 원본 Markdown은 절대 고치지 않고, 결과는 별도 보충 데이터로 저장해서 색인할 때만 합칩니다. 바뀌거나 지워진 이미지도 따라갑니다.

🛡️ 운영과 백업

  • Markdown Git 백업: 매시 정각에 .md만 비공개 GitHub 저장소로 push합니다. 파일이 2,000개 미만이거나, 한 번에 25개 이상이면서 10% 넘게 줄었거나, 용량이 25% 넘게 줄면 커밋 전에 멈춥니다. 무언가 잘못돼서 노트가 날아간 상태를 “정상 백업”으로 덮어쓰지 않으려고요. (CephFS 스냅샷과 3일마다 외장 HDD 전체 백업은 원래 있습니다.)
  • 런타임 캐시 스냅샷: 6시간마다 RBD 스냅샷, 30일 보존.
  • 알림: 인덱서, 스냅샷, 백업, OCR이 실패하거나 너무 오래 성공하지 않으면 Discord로 알림이 옵니다.

📝 정리

  • OneNote 이관은 변환보다 보존이 먼저입니다. 원본(HTML + 좌표 + 리소스)을 빠짐없이 받아두면 변환은 몇 번이고 다시 할 수 있습니다.
  • 내 노트에는 내 습관이 들어 있습니다(1×1 표 코드, -- 접두사). 변환기가 그 습관을 알아야 합니다.
  • pgvector vector HNSW는 2000차원까지. 큰 임베딩 모델은 차원을 줄여 요청하세요.
  • 코드 블록과 표는 청크 경계에서 자르지 마세요.
  • 증분 인덱싱은 “바뀐 것만”, 삭제는 “확실할 때만, 조금씩만”.
  • 옛 문서는 감점이 아니라 검색 단계 분리로.

이제 “예전에 RKE2 깔 때 Cilium을 tar로 설치했던 거 어떻게 했더라?” 하고 물어보면 제 노트에서 해당 페이지를 찾아서 링크까지 걸어줍니다. 몇 년 동안 쌓아둔 메모가 드디어 제대로 쓸모 있어진 느낌입니다. ㅎㅎ

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다