시놀로지 Drive × n8n 연동: 실시간 증분 임베딩 자동화 (5/5)

시리즈글 5 / 5 (완결)
업데이트2026년 5월
핵심 기술Folder Watch · 증분 임베딩 · n8n 자동화
고급
자동화 파이프라인 Folder Watch 증분 임베딩 n8n 연동 운영 최적화 장기 유지보수
매번 노트를 저장할 때마다 RAG 관리 화면을 열고 수동으로 “임베딩” 버튼을 누르는 구조는 지속 불가능합니다. 이 완결편에서는 시놀로지 Drive에서 파일이 저장되는 순간 자동으로 벡터 DB가 갱신되는 완전 자동화 파이프라인을 단계별로 구축합니다. 파일 쓰기 지연을 방지하는 펜딩/디바운스 메커니즘부터 컨테이너 환경을 고려한 n8n 오케스트레이션, 장기 운영 최적화까지 — 5부작 시리즈의 대미를 장식합니다.
SECTION01

증분 임베딩 자동화 설계 원리

파일 하나를 수정할 때마다 수천 개 노트 전체를 재임베딩하는 것은 시간·자원 낭비입니다. 실전 운영에서는 변경된 파일만 감지해 해당 파일의 벡터만 교체하는 증분 처리(Incremental Update)가 핵심입니다. 이 설계를 제대로 구현하면 노트 수천 개가 쌓여도 임베딩 처리 시간이 항상 초 단위로 유지됩니다.

🔄 완전 자동화 동기화 순환 흐름

📱 파일 변경 Drive 클라이언트 저장 → NAS 동기화 👁️ 변경 감지 Folder Watch / watchdog / inotify 🔢 증분 임베딩 변경 파일만 청킹 → 벡터 변환 🗄️ DB 업서트 ChromaDB / Qdrant 기존 벡터 교체 🤖 즉시 반영 RAG 검색에 신규 내용 즉시 적용 전체 사이클: 파일 저장 → 감지 → 임베딩 → DB 갱신까지 약 10~60초 (파일 크기·모델에 따라 상이)

🔍 파일 변경 감지 3가지 전략 비교

감지 방식동작 원리반응 속도CPU 부하추천 상황
inotify (Linux)커널 수준 파일 시스템 이벤트 감지즉시 (밀리초)매우 낮음별도 Linux 서버 or Docker 컨테이너 내부
Folder Watch UIAnythingLLM 내장 폴링 방식5분~1시간 설정낮음노코드 환경, 빠른 구축 우선
mtime + SHA256 해시파일 수정 시각 + 내용 해시 비교cron 주기 (1~5분)낮음범용적, NAS 내부 task scheduler 연동
💡
파일 식별 키 — SHA256 해시를 반드시 사용해야 하는 이유

파일명이나 mtime만으로 변경을 감지하면 복사·이름변경·타임스탬프 변조 시 오탐이 발생합니다. 파일 내용의 SHA256 해시를 DB에 기록해두고 비교하면 내용이 실제로 바뀐 파일만 정확하게 재임베딩할 수 있습니다.

SECTION02

AnythingLLM Folder Watch 실전 설정

AnythingLLM은 코딩 없이 UI에서 Folder Watch를 활성화할 수 있는 가장 간단한 방법입니다. 글 4에서 Docker Compose로 /volume1/AI_Knowledge_Base를 컨테이너 내부 /app/storage/documents/synology_notes로 마운트했다면, 이 설정만으로 완전 자동화가 완성됩니다.

⚙️ Folder Watch 활성화 단계별 가이드

1
AnythingLLM 접속
브라우저에서 http://[NAS_IP]:3001 접속. 우상단 설정(⚙️) 아이콘 → Document Management 클릭.
2
마운트된 폴더 확인
파일 탐색기에서 synology_notes 폴더가 보이는지 확인. 마크다운 파일들이 정상적으로 로드되어 있어야 합니다.
3
Workspace에 문서 추가
사용할 Workspace 선택 → Documents 탭 → synology_notes 폴더 전체 또는 하위 폴더별로 선택 → Save and Embed 클릭으로 초기 임베딩 실행.
4
Folder Watch 토글 활성화
Document Management → synology_notes 폴더 우측 👁️ Watch Folder for Changes 토글 ON. 스캔 주기는 기본 5분. 실시간성이 중요하다면 1분으로 조정.
5
자동 임베딩 확인
시놀로지 Drive 클라이언트에서 마크다운 파일 수정 → 저장 → 5분 후 AnythingLLM 채팅에서 새로운 내용이 검색되는지 테스트.
⚠️ Folder Watch 운영 시 주의사항
  • 대용량 파일 폭발적 추가 금지 — 한 번에 500개 이상 파일을 폴더에 복사하면 임베딩 큐가 폭증해 AnythingLLM이 멈출 수 있습니다. 배치 추가 시 100개 단위로 나눠서 진행하세요.
  • 이미지·PDF 포함 폴더 분리 — Folder Watch는 텍스트 파일에 최적화되어 있습니다. 이미지나 PDF가 섞여 있으면 처리 시간이 급증합니다. 마크다운 전용 폴더와 원본 파일 폴더를 분리 관리하세요.
  • Watch 폴더 경로 재설정 필요 — AnythingLLM 컨테이너를 재생성하면 Watch 설정이 초기화됩니다. docker-compose.yml에 Watch 폴더를 환경변수로 지정하는 방식으로 영속화하세요.
SECTION03

Python watchdog + 커스텀 증분 임베딩 파이프라인

AnythingLLM의 내장 Folder Watch 기능이 충분하지 않거나, Open WebUI·Dify 등 다른 RAG 프레임워크를 사용 중이라면 Python watchdog 라이브러리와 ChromaDB API를 직접 연동하는 커스텀 파이프라인을 구축합니다. 이 방식은 동기화 중 파일 쓰기가 끝나지 않은 상태에서 중복 트리거되어 데이터가 유실되는 스레드 경합(Race Condition) 문제를 해결하기 위해 디바운싱(Debouncing) 필터가 보강되었습니다.

📦 필수 패키지 설치

bash
pip install watchdog chromadb langchain-community \
            langchain-text-splitters sentence-transformers \
            python-frontmatter hashlib schedule

🐍 완전한 증분 임베딩 파이프라인 코드

synology_rag_watcher.py
import os
import hashlib
import json
import time
from threading import Timer
import frontmatter
import chromadb
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from langchain_text_splitters import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer

# ── 설정 ──────────────────────────────────────────────
WATCH_DIR   = os.getenv("WATCH_DIR", "/volume1/AI_Knowledge_Base")   # 환경변수 우선 적용
CHROMA_HOST = os.getenv("CHROMA_HOST", "localhost")
CHROMA_PORT = int(os.getenv("CHROMA_PORT", 8000))
COLLECTION  = "synology_notes"
HASH_DB     = "/app/data/file_hashes.json"   # 파일 해시 기록 DB
EMBED_MODEL = "BAAI/bge-m3"                  # 한국어 최적 임베딩 모델

# ── 초기화 ────────────────────────────────────────────
client     = chromadb.HttpClient(host=CHROMA_HOST, port=CHROMA_PORT)
collection = client.get_or_create_collection(
    name=COLLECTION,
    metadata={"hnsw:space": "cosine"}
)
embedder   = SentenceTransformer(EMBED_MODEL)
splitter   = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=150
)

def load_hash_db():
    if os.path.exists(HASH_DB):
        try:
            with open(HASH_DB, "r") as f:
                return json.load(f)
        except Exception:
            return {}
    return {}

def save_hash_db(db):
    os.makedirs(os.path.dirname(HASH_DB), exist_ok=True)
    with open(HASH_DB, "w") as f:
        json.dump(db, f, indent=2)

def file_sha256(path):
    if not os.path.exists(path):
        return ""
    h = hashlib.sha256()
    try:
        with open(path, "rb") as f:
            while chunk := f.read(8192):
                h.update(chunk)
        return h.hexdigest()
    except Exception:
        return ""

def upsert_file(path, hash_db):
    # 마크다운 파일을 청킹 → 임베딩 → ChromaDB 업서트 (안전성 강화)
    if not os.path.exists(path):
        return
    try:
        post = frontmatter.load(path)
        content  = post.content
        metadata = dict(post.metadata)
        
        # 메타데이터 값 타입 방어 (ChromaDB 제약조건에 맞춰 str/int/float/bool로 가공)
        metadata = {k: (str(v) if isinstance(v, (dict, list)) else v) for k, v in metadata.items()}
        metadata["source"] = path

        chunks = splitter.split_text(content)
        if not chunks:
            return

        new_hash = file_sha256(path)

        # 기존 벡터 삭제 (재임베딩 시 중복 인덱스 방지)
        existing = collection.get(where={"source": path})
        if existing["ids"]:
            collection.delete(ids=existing["ids"])

        # 새 벡터 생성 및 추가
        vectors = embedder.encode(chunks).tolist()
        ids     = [f"{hashlib.md5(path.encode()).hexdigest()}::chunk::{i}" for i in range(len(chunks))]
        metas   = [metadata] * len(chunks)

        collection.add(
            ids=ids,
            embeddings=vectors,
            documents=chunks,
            metadatas=metas
        )

        hash_db[path] = new_hash
        save_hash_db(hash_db)
        print(f"✅ 업서트 완료: {os.path.basename(path)} ({len(chunks)} chunks)")

    except Exception as e:
        print(f"❌ 오류 [{path}]: {e}")

def delete_file(path, hash_db):
    # 삭제된 파일의 벡터 제거
    try:
        existing = collection.get(where={"source": path})
        if existing["ids"]:
            collection.delete(ids=existing["ids"])
        hash_db.pop(path, None)
        save_hash_db(hash_db)
        print(f"🗑️  벡터 삭제: {os.path.basename(path)}")
    except Exception as e:
        print(f"❌ 삭제 오류 [{path}]: {e}")

# ── 디바운싱 지원 Watchdog 이벤트 핸들러 ────────────────────────────
class DebouncedNoteHandler(FileSystemEventHandler):
    def __init__(self):
        self.hash_db = load_hash_db()
        self.timers = {}

    def _should_process(self, path):
        return path.endswith(".md") and not os.path.basename(path).startswith(".")

    def on_modified(self, event):
        if event.is_directory or not self._should_process(event.src_path):
            return
        path = event.src_path
        
        # 연속 수정 파일 쓰기 스트림 병목 감쇄 (디바운싱 1.5초)
        if path in self.timers:
            self.timers[path].cancel()
        
        t = Timer(1.5, self._debounced_upsert, [path])
        self.timers[path] = t
        t.start()

    def _debounced_upsert(self, path):
        self.timers.pop(path, None)
        new_hash = file_sha256(path)
        if self.hash_db.get(path) != new_hash:
            print(f"🔄 변경 감지: {os.path.basename(path)}")
            upsert_file(path, self.hash_db)

    def on_created(self, event):
        if not event.is_directory and self._should_process(event.src_path):
            print(f"➕ 신규 파일: {os.path.basename(event.src_path)}")
            upsert_file(event.src_path, self.hash_db)

    def on_deleted(self, event):
        if not event.is_directory and self._should_process(event.src_path):
            print(f"🗑️  파일 삭제: {os.path.basename(event.src_path)}")
            delete_file(event.src_path, self.hash_db)

# ── 메인 실행 ─────────────────────────────────────────
if __name__ == "__main__":
    print(f"🚀 시놀로지 RAG Watcher 시작: {WATCH_DIR}")
    handler  = DebouncedNoteHandler()
    observer = Observer()
    observer.schedule(handler, WATCH_DIR, recursive=True)
    observer.start()
    try:
        while True:
            time.sleep(1)
    except KeyboardInterrupt:
        observer.stop()
    observer.join()

🐳 Docker 컨테이너로 Watcher 영속 실행

📁 Dockerfile.watcher
dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY synology_rag_watcher.py .

RUN pip install --no-cache-dir watchdog chromadb \
    langchain-text-splitters sentence-transformers \
    python-frontmatter

# bge-m3 모델 사전 다운로드 (이미지 빌드 시 포함)
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-m3')"

CMD ["python", "synology_rag_watcher.py"]
yaml — docker-compose.yml에 watcher 서비스 추가
  rag-watcher:
    build:
      context: .
      dockerfile: Dockerfile.watcher
    container_name: rag_watcher
    volumes:
      - /volume1/AI_Knowledge_Base:/volume1/AI_Knowledge_Base:ro  # 읽기 전용 마운트
      - rag_watcher_data:/app/data                                 # 해시 DB 영속화
    environment:
      - WATCH_DIR=/volume1/AI_Knowledge_Base
      - CHROMA_HOST=chromadb
    depends_on:
      - chromadb
    restart: unless-stopped

volumes:
  rag_watcher_data:
SECTION04

n8n으로 완전 자동화 오케스트레이션 구축

n8n은 시놀로지 NAS 환경에서 RAG 파이프라인 전체를 노코드로 오케스트레이션하는 최적의 도구입니다. 파일 변경 감지 → 임베딩 트리거 → 완료 알림 → 에러 핸들링까지 모든 흐름을 시각적 워크플로우로 관리할 수 있습니다.

🔧 n8n 시놀로지 NAS 설치 (Docker Compose)

yaml — n8n 서비스 추가
  n8n:
    image: n8nio/n8n:latest
    container_name: n8n_automation
    ports:
      - "5678:5678"
    environment:
      - N8N_BASIC_AUTH_ACTIVE=true
      - N8N_BASIC_AUTH_USER=admin
      - N8N_BASIC_AUTH_PASSWORD=your_secure_password
      - GENERIC_TIMEZONE=Asia/Seoul
      - N8N_HOST=0.0.0.0
    volumes:
      - n8n_data:/home/node/.n8n
      - /volume1/AI_Knowledge_Base:/data/knowledge:ro
    restart: unless-stopped

volumes:
  n8n_data:

🔗 핵심 자동화 워크플로우 설계

워크플로우트리거실행 작업알림
WF-1 노트 자동 임베딩Cron (5분마다) + 파일 변경 감지변경 파일 검출 → Watcher API 호출 → ChromaDB 업서트Slack / 텔레그램 완료 알림
WF-2 일일 재인덱싱 점검매일 새벽 3시 Cron해시 DB 점검 → 불일치 파일 재처리 → 통계 리포트이메일 일일 리포트
WF-3 에러 모니터링임베딩 오류 발생 시에러 로그 수집 → 오류 파일 격리 폴더 이동 → 재시도 3회텔레그램 긴급 알림
WF-4 주간 벡터DB 최적화매주 일요일 새벽 2시ChromaDB 볼륨 무결성 체크 → 정기 유실 아카이브 백업슬랙 주간 리포트

📋 WF-1 핵심 노드 구성 (n8n 워크플로우 JSON 요약)

n8n — HTTP Request 노드로 Watcher 트리거
# n8n HTTP Request 노드 설정 (컨테이너 매핑 경로 적용)
Method: POST
URL: http://rag-watcher:8080/trigger   # 가상 Watcher 엔드포인트
Body (JSON):
{
  "action": "scan_and_embed",
  "target_folder": "/data/knowledge",    # n8n 마운트 컨테이너 내부 경로 기준 수렴
  "force_reindex": false
}

# 응답 처리 데이터 수집 후 → Slack 알림 노드 매핑
Slack Message:
"✅ **RAG 지식 인덱스 동기화 완료**\n
 - 변경 감지 파일: {{ $json.processed_files_count }} 개\n
 - 새로 생성된 청크: {{ $json.new_chunks_count }} 개\n
 - 파이프라인 소요 시간: {{ $json.elapsed_time }}초"
✅ n8n + 시놀로지 시너지 활용 팁
  • 시놀로지 Task Scheduler 연동 — DSM Task Scheduler에서 n8n 워크플로우 Webhook을 호출해 시놀로지 이벤트(새 파일 업로드, 동기화 완료)와 직접 연동
  • Synology Drive API 활용 — n8n HTTP Request로 Drive API를 폴링해 최근 수정 파일 목록을 직접 가져오는 방식으로 인프라 부하 최소화
  • 모바일 Obsidian + 시놀로지 Drive + n8n — 스마트폰 Obsidian에서 저장 → Drive 동기화 → n8n 자동 임베딩 → 5분 내 AI 반영까지 완전 자동화
  • 웹훅으로 즉시 트리거 — n8n Webhook 노드를 활용해 특정 파일 저장 시 즉시 임베딩 API를 호출하도록 시놀로지 DSM 알림과 연동 가능
SECTION05

장기 운영 최적화 & 트러블슈팅

RAG 시스템은 구축 후 방치하면 수개월이 지나면서 성능이 저하됩니다. 벡터 DB 단편화, 삭제된 파일의 좀비 벡터, 임베딩 모델 버전 불일치 등이 누적되기 때문입니다. 최신 ChromaDB 아키텍처에 맞춘 가이드를 월 1~2회 실행하면 시스템이 항상 최적 상태를 유지합니다.

🛠️ 정기 유지보수 체크리스트

주기작업 항목실행 가이드 및 명령어
매일임베딩 스토리지 상태 보존ChromaDB 내부 WAL 엔진 도입으로 데이터 자동 플러시 보존 (명령어 필요 없음)
주간좀비 벡터 가비지 컬렉션`hash_db.json` 대조 후 실제 디렉터리에 소스 없는 ID 대상 `collection.delete`
월간전체 스냅샷 아카이브 백업docker run --rm -v chromadb_data:/data -v $(pwd):/backup alpine tar czf /backup/chroma_backup_$(date +%F).tar.gz /data
분기별모델 레이어 마이그레이션임베딩 모델 업그레이드 혹은 파라미터 변경 시 컬렉션 `drop` 후 완전 전수 재인덱싱

🔍 RAG 검색 품질 검증 테스트

python — RAG 검색 품질 벤치마크
import chromadb
from sentence_transformers import SentenceTransformer

client     = chromadb.HttpClient(host="localhost", port=8000)
col        = client.get_collection("synology_notes")
embedder   = SentenceTransformer("BAAI/bge-m3")

# 테스트 쿼리 목록
test_queries = [
    "2024년 KOSPI 하락기 대응 전략",
    "Docker 네트워크 트러블슈팅 방법",
    "가족 예방접종 스케줄",
    "기술 블로그 글쓰기 원칙",
]

for query in test_queries:
    vec = embedder.encode([query]).tolist()
    results = col.query(
        query_embeddings=vec,
        n_results=3,
        include=["documents", "metadatas", "distances"]
    )
    print(f"\n🔍 질문: {query}")
    for doc, meta, dist in zip(
        results["documents"][0],
        results["metadatas"][0],
        results["distances"][0]
    ):
        title    = meta.get("title", "제목 없음")
        score    = round((1 - dist) * 100, 1)   # 코사인 유사도 → 점수 변환
        print(f"  [{score}%] {title} — {doc[:60]}...")

⚡ 성능 병목 진단 & 해결

증상원인해결 방법
임베딩 처리가 갈수록 느려짐벡터 DB 인덱스 단편화ChromaDB 컬렉션 재생성 + 전체 재임베딩
엉뚱한 노트가 검색됨좀비 벡터 누적 또는 청크 크기 부적절좀비 벡터 정리 + 청크 사이즈 재조정
NAS CPU 사용률 100% 도달임베딩 모델이 CPU 과부하GPU 외부 연동 서버 분리 혹은 외부 엔드포인트 API 전환 고려
파일 변경인데 반영 안 됨hash_db 파일 손상 또는 권한 오류hash_db.json 삭제 후 Watcher 재시작 → 전체 재스캔
임베딩 후 검색 품질 저하메타데이터 내부 중첩 포셋 에러딕셔너리/배열 데이터 타입을 문자열(String) 포맷으로 변환 후 주입 확인
최종 시스템 검증 — 완전 자동화 확인 체크

스마트폰에서 메모 앱으로 새 노트 작성 → 시놀로지 Drive 동기화 → 5~10분 후 AnythingLLM 또는 Open WebUI 채팅에서 해당 내용이 검색되면 완전 자동화 파이프라인이 정상 작동하는 것입니다. 이제 당신은 메모만 하면 됩니다. AI가 알아서 학습합니다.

🎉
시놀로지 프라이빗 AI 지식 엔진 — 5부작 완결!
수년간 NAS 안에 잠들어 있던 메모, 독서록, 기술 노트, 업무 기록이
이제 당신만을 위한 살아 숨 쉬는 AI 지식 엔진으로 깨어났습니다.

노트를 쓰면 AI가 배우고, 질문하면 과거의 내가 답합니다.
데이터는 내 NAS 안에, 프라이버시는 100%, 비용은 최소화.

Leave a reply

Please enter your comment!
Please enter your name here