obsidian:~/vault$ search --hybrid obsidian

Example vault location

#

words: 8631 read_time: 44m updated: 2026-03-02 07:52
$ retriever search --hybrid obsidian

핵심 요약

노트 작성이 아닌 컨텍스트 엔지니어링. AI를 위한 Obsidian 볼트의 가치는 노트 자체가 아니라 노트를 검색 가능하게 만드는 검색 레이어에 있습니다. 검색 기능이 없는 16,000개 파일의 볼트는 쓰기 전용 데이터베이스에 불과합니다. 반면 hybrid 검색과 MCP 통합을 갖춘 200개 파일의 볼트는 AI 지식 기반이 됩니다. 검색 인프라가 곧 제품이며, 노트는 원재료입니다.

Hybrid 검색은 순수 키워드 검색이나 순수 시맨틱 검색보다 우수합니다. BM25는 정확한 식별자와 함수명을 포착합니다. 벡터 검색은 서로 다른 용어 간의 동의어와 개념적 일치를 포착합니다. Reciprocal Rank Fusion (RRF)은 점수 보정 없이 두 결과를 병합합니다. 어느 한 방법만으로는 두 가지 실패 모드를 모두 커버할 수 없습니다. MS MARCO 구절 랭킹 연구에서도 이 패턴이 확인됩니다: hybrid 검색은 단독 방법보다 일관되게 더 나은 성능을 보입니다.1 hybrid 검색기 심층 분석에서는 RRF 수학, 실제 숫자를 사용한 예제, 실패 모드 분석, 그리고 인터랙티브 퓨전 계산기를 다룹니다.

MCP는 AI 도구에 볼트 직접 접근을 제공합니다. Model Context Protocol (MCP) 서버는 검색기를 Claude Code, Codex CLI, Cursor 및 기타 AI 도구가 직접 호출할 수 있는 도구로 노출합니다. 에이전트가 볼트를 쿼리하면 출처 정보가 포함된 랭킹 결과를 수신하고, 전체 파일을 로드하지 않고도 해당 컨텍스트를 활용합니다. MCP 서버는 검색 엔진을 감싸는 얇은 래퍼입니다.

로컬 우선 방식은 API 비용이 제로이며 완전한 프라이버시를 보장합니다. 전체 스택이 단일 머신에서 실행됩니다: 저장은 SQLite, 임베딩(embeddings)은 Model2Vec, 키워드 검색은 FTS5, 벡터 KNN은 sqlite-vec을 사용합니다. 클라우드 서비스 없음, API 호출 없음, 네트워크 의존성 없음. 개인 노트는 절대로 머신을 떠나지 않습니다. 49,746개 청크(chunks) 전체를 재임베딩하면 OpenAI API 가격 기준으로 약 $0.30이 들지만, 실제 비용은 지연 시간, 프라이버시 노출, 그리고 오프라인에서 작동해야 할 시스템의 네트워크 의존성입니다.2

증분 인덱싱은 시스템을 10초 이내로 최신 상태로 유지합니다. 파일 수정 시간 비교를 통해 변경 사항을 감지합니다. 수정된 파일만 다시 청킹(chunking)하고 재임베딩합니다. 전체 재인덱싱은 Apple M 시리즈 하드웨어에서 약 4분이 소요됩니다. 일반적인 하루 편집량의 증분 업데이트는 10초 이내에 완료됩니다. 수동 개입 없이 시스템이 항상 최신 상태를 유지합니다.

아키텍처는 200개에서 20,000개 이상의 노트까지 확장됩니다. 동일한 3계층 설계(수집, 검색, 통합)가 모든 볼트 규모에서 작동합니다. 소규모 볼트에서 BM25 전용 검색으로 시작하세요. 키워드 충돌이 문제가 되면 벡터 검색을 추가하세요. 정확한 일치와 시맨틱 일치가 모두 필요할 때 RRF 퓨전을 추가하세요. 각 레이어는 독립적으로 유용하며 독립적으로 제거할 수 있습니다.


이 가이드 사용법

이 가이드는 전체 시스템을 다룹니다. 현재 상황에 따라 시작 지점이 달라집니다:

현재 상황 여기서 시작 이후 탐색
Obsidian + AI 입문자 왜 Obsidian이 AI 인프라에 적합한가, 빠른 시작 볼트 아키텍처, MCP 서버 아키텍처
기존 볼트 보유, AI 접근 희망 MCP 서버 아키텍처, Claude Code 통합 임베딩 모델, 전문 검색
검색 시스템 구축 중 완전한 검색 파이프라인, Reciprocal Rank Fusion 성능 튜닝, 문제 해결
팀 또는 엔터프라이즈 환경 의사결정 프레임워크, 지식 그래프 패턴 개발자 워크플로우 레시피, 마이그레이션 가이드

Contract으로 표시된 섹션은 구현 세부사항, 설정 블록, 실패 모드를 포함합니다. Narrative로 표시된 섹션은 개념, 아키텍처 결정, 설계 선택의 근거에 중점을 둡니다. Recipe로 표시된 섹션은 단계별 워크플로우를 제공합니다.


왜 Obsidian이 AI 인프라에 적합한가

이 가이드의 핵심 논지: Obsidian 볼트는 로컬 우선, 플레인텍스트, 그래프 구조이며 사용자가 스택의 모든 레이어를 제어할 수 있기 때문에 개인 AI 지식 기반을 위한 최적의 기반입니다.

Obsidian이 AI에 제공하는 대안에 없는 것들

플레인텍스트 마크다운 파일. 모든 노트는 파일 시스템의 .md 파일입니다. 독점 포맷 없음, 데이터베이스 내보내기 없음, 콘텐츠를 읽기 위한 API 불필요. 파일을 읽을 수 있는 모든 도구가 볼트를 읽을 수 있습니다. grep, ripgrep, Python의 pathlib, SQLite FTS5 — 모두 소스 파일에서 직접 작동합니다. 검색 시스템을 구축할 때 인덱싱하는 대상은 API 응답이 아닌 파일입니다. 소스가 파일 시스템 자체이므로 인덱스는 항상 소스와 일치합니다.

로컬 우선 아키텍처. 볼트는 사용자의 머신에 존재합니다. 서버 없음, 클라우드 동기화 의존성 없음, API 속도 제한 없음, 사용자 콘텐츠 처리 방식을 규정하는 서비스 약관 없음. 외부 서비스 없이 노트를 임베딩하고, 인덱싱하고, 청킹하고, 검색할 수 있습니다. 이것이 AI 인프라에 중요한 이유는 검색 파이프라인이 API 엔드포인트 응답 속도가 아닌 디스크 속도만큼 빠르게 실행되기 때문입니다. 프라이버시 측면에서도 중요합니다: 자격 증명, 건강 데이터, 금융 정보, 개인적인 기록이 포함된 노트가 절대로 머신을 떠나지 않습니다.

wiki-link를 통한 그래프 구조. Obsidian의 [[wiki-link]] 구문은 노트 간 방향 그래프를 생성합니다. OAuth 구현에 대한 노트는 토큰 로테이션, 세션 관리, API 보안에 대한 노트로 연결됩니다. 그래프 구조는 개념 간의 인간이 큐레이션한 관계를 인코딩합니다. 벡터 임베딩은 시맨틱 유사성을 포착하지만, wiki-link는 저자가 주제에 대해 사고하면서 만든 의도적 연결을 포착합니다. 그래프는 임베딩이 복제할 수 없는 신호입니다.

플러그인 생태계. Obsidian에는 1,800개 이상의 커뮤니티 플러그인이 있습니다. Dataview는 볼트를 데이터베이스처럼 쿼리합니다. Templater는 JavaScript 로직으로 템플릿에서 노트를 생성합니다. Git 통합은 볼트를 리포지토리에 동기화합니다. Linter는 포맷 일관성을 강제합니다. 이러한 플러그인은 기반 플레인텍스트 포맷을 변경하지 않고 볼트에 구조를 추가합니다. 검색 시스템은 플러그인 자체가 아닌 플러그인의 출력을 인덱싱합니다.

500만 명 이상의 사용자. Obsidian에는 템플릿, 워크플로우, 플러그인, 문서를 생산하는 대규모 활성 커뮤니티가 있습니다. 볼트 구성이나 플러그인 설정 문제가 발생하면 누군가가 이미 해결 방법을 문서화했을 가능성이 높습니다. 커뮤니티는 또한 Obsidian 인접 도구를 생산합니다: MCP 서버, 인덱싱 스크립트, 퍼블리싱 파이프라인, API 래퍼 등.

파일 시스템만으로는 제공되지 않는 것들

마크다운 파일의 디렉토리는 플레인텍스트의 이점을 갖지만 Obsidian이 추가하는 세 가지가 부족합니다:

  1. 양방향 링크. Obsidian은 backlink를 자동으로 추적합니다. 노트 A에서 노트 B로 링크하면, 노트 B에 노트 A가 참조하고 있음이 표시됩니다. 그래프 패널은 연결 클러스터를 시각화합니다. 이 양방향 인식은 순수 파일 시스템이 제공하지 않는 메타데이터입니다.

  2. 플러그인 렌더링이 포함된 라이브 프리뷰. Dataview 쿼리, Mermaid 다이어그램, 콜아웃 블록이 실시간으로 렌더링됩니다. 작성 경험은 텍스트 에디터보다 풍부하지만 저장 포맷은 플레인텍스트로 유지됩니다. 풍부한 환경에서 작성하고 정리하며, 검색 시스템은 원시 마크다운을 인덱싱합니다.

  3. 커뮤니티 인프라. 플러그인 탐색, 테마 마켓플레이스, 동기화 서비스(선택 사항), 퍼블리시 서비스(선택 사항), 문서 생태계. 개별 기능은 독립 도구로 복제할 수 있지만, Obsidian은 이를 일관된 워크플로우로 패키징합니다.

Obsidian이 하지 않는 것 (그리고 사용자가 구축하는 것)

Obsidian에는 검색 인프라가 포함되어 있지 않습니다. 기본 검색(전문 검색, 파일명, 태그)은 있지만 임베딩 파이프라인, 벡터 검색, 퓨전 랭킹, MCP 서버, 자격 증명 필터링, 청킹 전략, 외부 AI 도구용 통합 훅은 없습니다. 이 가이드는 Obsidian 위에 구축하는 인프라를 다룹니다. 볼트는 기반이며, 검색 파이프라인, MCP 서버, 통합 훅이 인프라입니다.

여기에 설명된 아키텍처는 마크다운 우선이며 Obsidian 전용이 아닙니다. Logseq, Foam, Dendron 또는 마크다운 파일의 일반 디렉토리를 사용하더라도 검색 파이프라인은 동일하게 작동합니다. 청커는 .md 파일을 읽습니다. 임베더는 텍스트 문자열을 처리합니다. 인덱서는 SQLite에 기록합니다. 이 구성 요소 중 어느 것도 Obsidian 고유 기능에 의존하지 않습니다. Obsidian의 기여는 검색기가 인덱싱하는 마크다운 파일을 생산하는 작성 및 구성 환경입니다.


빠른 시작: 첫 번째 AI 연결 Vault

이 섹션에서는 5분 안에 vault를 AI 도구에 연결하는 방법을 안내합니다. Obsidian을 설치하고, vault를 생성하고, MCP 서버를 설치한 후 첫 번째 쿼리를 실행합니다. 이 빠른 시작에서는 즉각적인 결과를 위해 커뮤니티 MCP 서버를 사용합니다. 이후 섹션에서는 프로덕션용 맞춤형 검색 파이프라인 구축을 다룹니다.

사전 요구 사항

  • macOS, Linux 또는 Windows
  • Node.js 18+ (MCP 서버용)
  • Claude Code, Codex CLI 또는 Cursor 설치 완료

1단계: Vault 생성

obsidian.md에서 Obsidian을 다운로드하고 새 vault를 생성하세요. 기억하기 쉬운 위치를 선택하세요 — MCP 서버에 절대 경로가 필요합니다.

# Example vault location
~/Documents/knowledge-base/

검색기가 작동할 수 있도록 몇 개의 노트를 추가하세요. 10~20개의 노트만으로도 결과를 확인할 수 있습니다. 각 노트는 의미 있는 제목과 최소 한 단락의 내용이 포함된 .md 파일이어야 합니다.

2단계: MCP 서버 설치

obsidian-mcp 커뮤니티 서버는 즉각적인 vault 접근을 제공합니다. 다음과 같이 설치하세요:

npm install -g obsidian-mcp-server

3단계: AI 도구 설정

Claude Code~/.claude/settings.json에 추가:

{
  "mcpServers": {
    "obsidian": {
      "command": "obsidian-mcp-server",
      "args": ["--vault", "/absolute/path/to/your/vault"]
    }
  }
}

Codex CLI.codex/config.toml에 추가:

[mcp_servers.obsidian]
command = "obsidian-mcp-server"
args = ["--vault", "/absolute/path/to/your/vault"]

Cursor.cursor/mcp.json에 추가:

{
  "mcpServers": {
    "obsidian": {
      "command": "obsidian-mcp-server",
      "args": ["--vault", "/absolute/path/to/your/vault"]
    }
  }
}

4단계: 첫 번째 쿼리 실행

AI 도구를 열고 vault 노트가 답할 수 있는 질문을 하세요:

Search my Obsidian vault for notes about [topic you wrote about]

AI 도구가 MCP 서버를 호출하면, 서버가 vault를 검색하고 일치하는 콘텐츠를 반환합니다. 파일 경로와 관련 발췌문이 포함된 결과를 확인할 수 있습니다.

지금까지 구축한 것

로컬 지식 베이스를 표준 프로토콜을 통해 AI 도구에 연결했습니다. MCP 서버가 vault 파일을 읽고, 기본 검색을 수행하며, 결과를 반환합니다. 이것이 최소 기능 버전입니다.

이 빠른 시작에서 제공하지 않는 기능: - 하이브리드 검색 (BM25 + 벡터 검색 + RRF 융합) - 임베딩 기반 의미 검색 - 자격 증명 필터링 - 증분 인덱싱 - Hook 기반 자동 컨텍스트 주입

이 가이드의 나머지 부분에서 이러한 각 기능의 구축 방법을 다룹니다. 빠른 시작은 개념을 증명합니다. 전체 파이프라인은 프로덕션 수준의 검색을 제공합니다.


의사 결정 프레임워크: Obsidian vs 대안

모든 사용 사례에 Obsidian이 필요한 것은 아닙니다. 이 섹션에서는 Obsidian이 적합한 기반인 경우, 과도한 경우, 그리고 다른 도구가 더 적합한 경우를 구분합니다.

의사 결정 트리

START: What is your primary content type?

├─ Structured data (tables, records, schemas)
   Use a database. SQLite, PostgreSQL, or a spreadsheet.
   Obsidian is for prose, not tabular data.

├─ Ephemeral context (current project, temporary notes)
   Use CLAUDE.md / AGENTS.md in the project repo.
   These travel with the code and reset per project.

├─ Team wiki (shared documentation, onboarding)
   Evaluate Notion, Confluence, or a shared git repo.
   Obsidian vaults are personal-first. Team sync is possible
    but not native.

└─ Growing personal knowledge corpus
   
   ├─ < 50 notes
      A folder of markdown files + grep is sufficient.
      Obsidian adds value mainly through the link graph,
       which needs density to be useful.
   
   ├─ 50 - 500 notes
      Obsidian adds value. Wiki-links create a navigable graph.
      BM25-only search (FTS5) is sufficient at this scale.
      Skip vector search and RRF until keyword collisions appear.
   
   ├─ 500 - 5,000 notes
      Full hybrid retrieval becomes valuable. Keyword collisions
       increase. Semantic search catches queries that BM25 misses.
      Add vector search + RRF fusion at this scale.
   
   └─ 5,000+ notes
       Full pipeline is essential. BM25-only returns too much noise.
       Credential filtering becomes critical (more notes = more
        accidentally pasted secrets).
       Incremental indexing matters (full reindex takes minutes).
       MCP integration pays dividends on every AI interaction.

비교 매트릭스

기준 Obsidian Notion Apple Notes 일반 파일 시스템 CLAUDE.md
로컬 우선 아니오 (클라우드) 부분적 (iCloud)
일반 텍스트 예 (markdown) 아니오 (블록) 아니오 (독점 형식)
그래프 구조 예 (wiki-link) 부분적 (멘션) 아니오 아니오 아니오
AI 인덱싱 가능 직접 파일 접근 API 필요 내보내기 필요 직접 파일 접근 이미 컨텍스트에 포함
플러그인 생태계 1,800개 이상 통합 기능 없음 해당 없음 해당 없음
오프라인 사용 전체 지원 읽기 전용 캐시 부분적 전체 지원 전체 지원
10K+ 노트 확장 예 (API 사용 시) 성능 저하 아니오 (단일 파일)
비용 무료 (코어) $10/월 이상 무료 무료 무료

Obsidian이 과도한 경우

  • 단일 프로젝트 컨텍스트. AI가 현재 코드베이스에 대한 컨텍스트만 필요한 경우, CLAUDE.md, AGENTS.md 또는 프로젝트 수준 문서에 넣으세요. 이 파일들은 저장소와 함께 이동하며 자동으로 로드됩니다.
  • 구조화된 데이터. 콘텐츠가 테이블, 레코드 또는 스키마인 경우 데이터베이스를 사용하세요. Obsidian 노트는 산문 중심입니다. Dataview가 frontmatter 필드를 쿼리할 수 있지만, 구조화된 쿼리는 실제 데이터베이스가 더 잘 처리합니다.
  • 임시 리서치. 프로젝트 종료 후 노트를 폐기할 예정이라면, markdown 파일이 있는 임시 디렉토리가 더 간단합니다. 일시적인 콘텐츠를 위해 검색 인프라를 구축하지 마세요.

Obsidian이 적합한 경우

  • 수개월 또는 수년에 걸쳐 지식을 축적하는 경우. 말뭉치가 성장함에 따라 가치가 복리로 증가합니다. 6개월 동안 매일 쿼리하는 200개 노트의 vault가 한 번만 쿼리하는 5,000개 노트의 vault보다 더 많은 가치를 제공합니다.
  • 하나의 말뭉치에 여러 도메인이 포함된 경우. 프로그래밍, 아키텍처, 보안, 디자인, 개인 프로젝트에 대한 노트를 포함하는 vault는 프로젝트별 CLAUDE.md가 제공할 수 없는 교차 도메인 검색의 혜택을 받습니다.
  • 프라이버시에 민감한 콘텐츠. 로컬 우선이란 검색 파이프라인이 콘텐츠를 외부 서비스로 전송하지 않는다는 의미입니다. Vault에는 클라우드 서비스에 업로드하지 않을 콘텐츠를 포함하여 원하는 모든 것을 넣을 수 있습니다.

멘탈 모델: 세 가지 레이어

이 시스템은 독립적으로 작동하지만 결합할 때 시너지를 발휘하는 세 개의 레이어로 구성됩니다. 각 레이어는 서로 다른 관심사와 서로 다른 실패 모드를 가집니다.

┌─────────────────────────────────────────────────────┐
                 INTEGRATION LAYER                     
  MCP servers, hooks, skills, context injection        
  Concern: delivering context to AI tools              
  Failure: wrong context, too much context, stale      
└──────────────────────┬──────────────────────────────┘
                        query + ranked results
┌──────────────────────┴──────────────────────────────┐
                  RETRIEVAL LAYER                      
  BM25, vector KNN, RRF fusion, token budget           
  Concern: finding the right content for any query     
  Failure: wrong ranking, missed results, slow queries 
└──────────────────────┬──────────────────────────────┘
                        chunked, embedded, indexed
┌──────────────────────┴──────────────────────────────┐
                   INTAKE LAYER                        
  Note creation, signal triage, vault organization     
  Concern: what enters the vault and how it's stored   │
  Failure: noise, duplicates, missing structure        
└─────────────────────────────────────────────────────┘

수집(Intake)은 vault에 무엇이 들어오는지 결정합니다. 큐레이션 없이는 vault에 노이즈가 축적됩니다: 트윗 스크린샷, 주석 없이 복사-붙여넣기한 글, 맥락 없는 미완성 생각들. 수집 레이어는 진입 시점에서의 품질 관리를 담당합니다. 점수 매기기 파이프라인, 태깅 규칙, 수동 검토 프로세스 등 — vault에 검색할 가치가 있는 콘텐츠가 포함되도록 보장하는 모든 메커니즘이 해당됩니다.

검색(Retrieval)은 vault를 쿼리 가능하게 만듭니다. 이것이 엔진입니다: 노트를 검색 단위로 청킹(chunking)하고, 청크를 벡터 공간에 임베딩(embedding)하며, 키워드 및 의미 검색을 위해 인덱싱하고, RRF로 결과를 융합합니다. 검색 레이어는 파일 디렉토리를 쿼리 가능한 지식 베이스로 변환합니다. 이 레이어가 없으면 vault는 수동 탐색과 기본 검색을 통해서만 탐색할 수 있으며, AI 도구가 프로그래밍 방식으로 접근할 수 없습니다.

통합(Integration)은 검색 레이어를 AI 도구에 연결합니다. MCP 서버는 검색을 호출 가능한 도구로 노출합니다. Hook은 자동으로 컨텍스트를 주입합니다. 스킬은 새로운 지식을 vault에 다시 캡처합니다. 통합 레이어는 지식 베이스와 이를 소비하는 AI 에이전트 사이의 인터페이스입니다.

레이어는 설계상 분리되어 있습니다. 수집 점수 매기기 파이프라인은 임베딩에 대해 알지 못합니다. 검색기는 신호 라우팅 규칙에 대해 알지 못합니다. MCP 서버는 노트가 어떻게 생성되었는지 알지 못합니다. 이러한 분리는 각 레이어를 독립적으로 개선할 수 있음을 의미합니다. 수집 파이프라인을 변경하지 않고 임베딩 모델을 교체할 수 있습니다. 검색기를 수정하지 않고 새로운 MCP 기능을 추가할 수 있습니다. 인덱스를 건드리지 않고 신호 점수 매기기 휴리스틱을 변경할 수 있습니다.


AI 활용을 위한 Vault 아키텍처

AI 검색에 최적화된 vault는 개인 브라우징에 최적화된 vault와는 다른 관례를 따릅니다. 이 섹션에서는 폴더 구조, 노트 스키마, frontmatter 관례, 그리고 검색 품질을 향상시키는 구체적인 패턴을 다룹니다.

폴더 구조

최상위 폴더에 번호 접두사를 사용하여 예측 가능한 조직 계층을 만드세요. 번호는 우선순위를 의미하지 않으며, 관련 도메인을 그룹화하고 구조를 한눈에 파악할 수 있게 합니다.

vault/
├── 00-inbox/              # Unsorted captures, pending triage
├── 01-projects/           # Active project notes
├── 02-areas/              # Ongoing areas of responsibility
├── 03-resources/          # Reference material by topic
   ├── programming/
   ├── security/
   ├── ai-engineering/
   ├── design/
   └── devops/
├── 04-archive/            # Completed projects, old references
├── 05-signals/            # Scored signal intake
   ├── ai-tooling/
   ├── security/
   ├── systems/
   └── ...12 domain folders
├── 06-daily/              # Daily notes (if used)
├── 07-templates/          # Note templates (excluded from index)
├── 08-attachments/        # Images, PDFs (excluded from index)
├── .obsidian/             # Obsidian config (excluded from index)
└── .indexignore            # Paths to exclude from retrieval index

인덱싱해야 하는 폴더: 마크다운 산문이 포함된 모든 폴더 — projects, areas, resources, signals, daily notes.

인덱싱에서 제외해야 하는 폴더: Templates(콘텐츠가 아닌 플레이스홀더 변수를 포함), attachments(바이너리 파일), Obsidian 설정, 그리고 검색 인덱스에 포함하고 싶지 않은 민감한 콘텐츠가 있는 모든 폴더.

.indexignore 파일

vault 루트에 .indexignore 파일을 생성하여 검색 인덱스에서 명시적으로 경로를 제외하세요. 구문은 .gitignore와 동일합니다:

# Obsidian internal
.obsidian/

# Templates contain placeholders, not content
07-templates/

# Binary attachments
08-attachments/

# Personal health/medical notes
02-areas/health/

# Financial records
02-areas/finance/personal/

# Career documents (resumes, salary data)
02-areas/career/private/

인덱서는 스캔 전에 이 파일을 읽고 일치하는 경로를 완전히 건너뜁니다. 제외된 경로의 파일은 청킹(chunking)되지 않고, 임베딩(embedding)되지 않으며, 검색 결과에 나타나지 않습니다.

노트 스키마

모든 노트에는 YAML frontmatter가 있어야 합니다. 검색기는 frontmatter 필드를 필터링과 컨텍스트 보강에 활용합니다:

---
title: "OAuth Token Rotation Patterns"
type: note           # note | signal | project | moc | daily
domain: security     # primary domain for routing
tags:
  - authentication
  - oauth
  - token-management
created: 2026-01-15
updated: 2026-02-28
source: ""           # URL if captured from external source
status: active       # active | archived | draft
---

검색에 필수적인 필드:

  • title — 검색 결과 표시와 BM25 제목 컨텍스트에 사용됩니다
  • type — 유형별 필터 쿼리를 가능하게 합니다(“MOC만 표시” 또는 “signal만 표시”)
  • tags — FTS5 제목 컨텍스트에서 0.3 가중치로 인덱싱되어, 본문에서 다른 용어를 사용하더라도 키워드 매칭을 제공합니다

선택 사항이지만 유용한 필드:

  • domain — 도메인 범위 쿼리를 가능하게 합니다(“보안 노트만 검색”)
  • source — 캡처된 콘텐츠의 출처 표시; 검색기가 결과에 소스 URL을 포함할 수 있습니다
  • status — 활성 검색에서 보관된 노트나 초안 노트를 제외할 수 있습니다

청킹(Chunking) 관례

검색기는 H2(##) 제목 경계에서 청킹합니다. 이는 노트 구조가 검색 세분성에 직접적으로 영향을 미친다는 것을 의미합니다:

검색에 적합한 구조:

## Token Rotation Strategy

The rotation interval depends on the threat model...

## Implementation with refresh_token

The OAuth 2.0 refresh token flow requires...

## Error Handling: Expired Tokens

When a token expires mid-request...

세 개의 H2 섹션은 독립적으로 검색 가능한 세 개의 청크를 생성합니다. 각 청크는 임베딩이 의미를 포착하기에 충분한 컨텍스트를 갖고 있습니다. “만료된 토큰 처리”에 대한 쿼리는 세 번째 청크에 구체적으로 매칭됩니다.

검색에 부적합한 구조:

# OAuth Notes

Token rotation depends on threat model. The OAuth 2.0 refresh
token flow requires storing the refresh token securely. When a
token expires mid-request, the client should retry after refresh.
The rotation interval is typically 15-30 minutes for access tokens
and 7-30 days for refresh tokens...

H2 제목이 없는 하나의 긴 섹션은 하나의 큰 청크를 생성합니다. 임베딩은 섹션 내 모든 주제의 평균값을 산출합니다. 어떤 하위 주제에 대한 쿼리든 전체 노트에 동일하게 매칭됩니다.

경험 법칙: 한 섹션이 둘 이상의 개념을 다루고 있다면, H2 하위 섹션으로 분리하세요. 나머지는 청커가 처리합니다.

노트에 넣지 말아야 할 것

검색 품질을 저하시키는 콘텐츠:

  • 주석 없이 전체 기사를 그대로 복사 붙여넣기. 검색기가 원본 기사의 키워드를 인덱싱하여, 직접 작성하지 않은 콘텐츠로 vault가 희석됩니다. 대신 요약을 추가하거나, 핵심 포인트를 추출하거나, 소스 URL에 링크하세요.
  • 텍스트 설명 없는 스크린샷. 검색기는 마크다운 텍스트를 인덱싱합니다. 대체 텍스트나 주변 설명이 없는 이미지는 BM25와 벡터 검색 모두에서 보이지 않습니다.
  • 자격 증명 문자열. API 키, 토큰, 비밀번호, 연결 문자열. 자격 증명 필터링이 있더라도, 가장 안전한 접근 방식은 노트에 시크릿을 절대 붙여넣지 않는 것입니다. 대신 이름으로 참조하세요(“the Cloudflare API token in ~/.env”).
  • 큐레이션 없는 자동 생성 콘텐츠. 도구가 노트를 생성하는 경우(회의 녹취록, Readwise 하이라이트, RSS 가져오기), 영구 vault에 넣기 전에 검토하고 주석을 달아야 합니다. 큐레이션되지 않은 자동 가져오기는 검색 가능한 가치 없이 양만 늘립니다.

AI 워크플로우를 위한 플러그인 생태계

AI 검색을 위한 vault 품질을 향상시키는 Obsidian 플러그인은 세 가지 범주로 나뉩니다: 구조적(일관성 강화), 쿼리(메타데이터 노출), 동기화(vault 최신 상태 유지).

필수 플러그인

Dataview. frontmatter 필드를 사용하여 vault를 데이터베이스처럼 쿼리합니다. 동적 인덱스를 생성할 수 있습니다: “최근 30일 내에 업데이트된 security 태그가 달린 모든 노트” 또는 “상태가 active인 모든 프로젝트 노트.” Dataview는 검색을 직접적으로 돕지는 않지만, vault 커버리지의 공백을 식별하고 업데이트가 필요한 노트를 찾는 데 도움이 됩니다.

TABLE type, domain, updated
FROM "03-resources"
WHERE status = "active"
SORT updated DESC
LIMIT 20

Templater. 동적 필드가 있는 템플릿으로 노트를 생성합니다. created, type, domain 필드를 미리 채우는 템플릿을 사용하여 모든 새 노트가 올바른 frontmatter로 시작되도록 보장합니다. 일관된 frontmatter는 검색 필터링을 향상시킵니다.

<%* /* New Resource Note Template */ %>
---
title: "<% tp.file.cursor() %>"
type: note
domain: <% tp.system.suggester(["programming", "security", "ai-engineering", "design", "devops"], ["programming", "security", "ai-engineering", "design", "devops"]) %>
tags: []
created: <% tp.date.now("YYYY-MM-DD") %>
updated: <% tp.date.now("YYYY-MM-DD") %>
source: ""
status: active
---

## Key Points

## Details

## 참고 자료

Linter. 볼트 전체에 서식 규칙을 적용합니다. 일관된 제목 계층 구조(H1은 타이틀, H2는 섹션, H3는 하위 섹션)를 유지하면 청커가 예측 가능한 결과를 생성합니다. 검색에 영향을 미치는 Linter 규칙은 다음과 같습니다:

  • 제목 증가: 순차적 제목 레벨 적용(H1에서 H3로 건너뛰기 금지)
  • YAML title: 파일명과 일치
  • 후행 공백: 제거(FTS5 토큰화 아티팩트 방지)
  • 연속 빈 줄: 1줄로 제한(더 깔끔한 청크 생성)

Git 통합. 볼트를 위한 버전 관리입니다. 시간 경과에 따른 변경 사항을 추적하고, 기기 간 동기화하며, 실수로 인한 삭제에서 복구할 수 있습니다. Git은 또한 인덱서가 증분 변경 감지에 사용하는 mtime 데이터를 제공합니다.

인덱싱에 도움이 되는 플러그인

Smart Connections. Obsidian 내에서 AI 기반 시맨틱 검색을 제공하는 Obsidian 플러그인입니다. 자체 임베딩(embedding) 인덱스를 생성합니다. 이 가이드의 검색 시스템은 Obsidian 외부에서 실행되는(Python 파이프라인으로 동작하는) 반면, Smart Connections는 글을 작성하면서 의미적 관계를 탐색하는 데 유용합니다. 두 시스템은 동일한 콘텐츠를 인덱싱하지만 서로 다른 용도를 제공합니다: Smart Connections는 에디터 내 탐색용, 외부 검색기는 AI 도구 통합용입니다.

Metadata Menu. 필드 값 자동 완성 기능이 포함된 구조화된 frontmatter 편집을 제공합니다. type, domain, tags 필드의 오타를 줄여줍니다. 일관된 메타데이터는 검색 필터링 정확도를 향상시킵니다.

인덱싱에 해로운 플러그인

Excalidraw. 마크다운 파일에 JSON로 내장된 형태로 드로잉을 저장합니다. JSON는 구문적으로 유효한 마크다운이지만, 청킹(chunking)하고 임베딩하면 의미 없는 데이터가 생성됩니다. .indexignore를 통해 또는 파일 확장자로 필터링하여 Excalidraw 파일을 인덱스에서 제외하세요.

Kanban. 보드 상태를 특수 형식의 마크다운으로 저장합니다. 이 형식은 Kanban 렌더링을 위해 설계된 것이지, 산문 검색용이 아닙니다. 청커가 카드 제목과 메타데이터의 단편만 생성하여 임베딩 품질이 낮습니다. Kanban 보드를 인덱스에서 제외하세요.

Calendar. 최소한의 콘텐츠(종종 날짜 헤더만)로 일일 노트를 생성합니다. 비어 있거나 거의 빈 노트는 저품질 청크를 생성합니다. 일일 노트를 사용하는 경우, 실질적인 내용을 작성하거나 일일 노트 폴더를 인덱스에서 제외하세요.

중요한 플러그인 설정

파일 복구 → 활성화. 실수로 인한 노트 삭제를 방지합니다. 검색과 직접적인 관련은 없지만, 의존하는 지식 베이스에는 필수적입니다.

엄격한 줄 바꿈 → 비활성화. 마크다운 표준 줄 바꿈(문단 구분을 위한 이중 줄 바꿈)이 Obsidian의 엄격 모드(단일 줄 바꿈으로 <br> 처리)보다 더 깔끔한 청크를 생성합니다.

새 파일 기본 위치 → 지정된 폴더. 새 파일을 00-inbox/로 라우팅하여 분류되지 않은 노트가 도메인 폴더를 오염시키지 않도록 합니다. 인박스는 스테이징 영역으로, 분류 후 파일을 도메인 폴더로 이동합니다.

Wiki-link 형식 → 가능한 경우 최단 경로. 짧은 링크 대상은 검색기가 링크 구조를 인덱싱할 때 더 쉽게 해석할 수 있습니다.


Embedding 모델: 선택과 설정

Embedding 모델은 텍스트 청크(chunk)를 시맨틱 검색을 위한 수치 벡터로 변환합니다. 모델 선택에 따라 검색 품질, 인덱스 크기, 임베딩(embedding) 속도, 런타임 의존성이 결정됩니다. 이 섹션에서는 Model2Vec의 potion-base-8M이 기본 선택인 이유와 대안을 선택해야 하는 경우를 설명합니다.

Model2Vec potion-base-8M을 선택하는 이유

모델: minishlab/potion-base-8M 파라미터: 760만 차원: 256 크기: ~30 MB 의존성: model2vec (numpy만 필요, PyTorch 불필요) 추론: CPU 전용, 정적 단어 임베딩 (어텐션 레이어 없음)

Model2Vec은 문장 트랜스포머의 지식을 정적 토큰 임베딩으로 증류합니다. BERT, MiniLM 및 기타 트랜스포머 모델처럼 입력에 대해 어텐션 레이어를 실행하는 대신, Model2Vec은 사전 계산된 토큰 임베딩의 가중 평균을 통해 벡터를 생성합니다.3 실질적인 결과: 순차 연산이 없기 때문에 임베딩 속도가 트랜스포머 기반 모델보다 50~500배 빠릅니다.

MTEB 벤치마크 스위트에서 potion-base-8M은 all-MiniLM-L6-v2 성능의 89%를 달성합니다 (평균 50.03 대 56.09).4 11%의 품질 차이는 속도와 단순성의 이점에 대한 트레이드오프입니다. 짧은 마크다운 청크(일반적인 볼트에서 평균 200~400단어)의 경우, 두 모델 모두 짧고 집중된 텍스트에 대해 유사한 표현으로 수렴하기 때문에 품질 차이가 긴 문서보다 덜 두드러집니다.

설정

# embedder.py
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256

class Model2VecEmbedder:
    def __init__(self, model_name=DEFAULT_MODEL):
        self._model_name = model_name
        self._model = None

    def _ensure_model(self):
        if self._model is not None:
            return
        _activate_venv()  # Add isolated venv to sys.path
        from model2vec import StaticModel
        self._model = StaticModel.from_pretrained(self._model_name)

    def embed_batch(self, texts):
        self._ensure_model()
        vecs = self._model.encode(texts)
        return [v.tolist() for v in vecs]

지연 로딩. 모델은 임포트 시가 아닌 처음 사용할 때 로드됩니다. 리트리버가 BM25 전용 폴백 모드로 동작할 때(예: 임베딩 venv가 설치되지 않은 경우) 임베더 모듈을 임포트하는 비용은 없습니다.

격리된 가상 환경. 모델은 나머지 도구 체인과의 의존성 충돌을 방지하기 위해 전용 venv(예: ~/.claude/venvs/memory/)에서 실행됩니다. _activate_venv() 함수는 런타임에 venv의 site-packagessys.path에 추가합니다.

# Create isolated venv
python3 -m venv ~/.claude/venvs/memory
~/.claude/venvs/memory/bin/pip install model2vec

배치 처리. 임베더는 Model2Vec의 오버헤드를 분산시키기 위해 64개 단위의 배치로 텍스트를 처리합니다. 인덱서는 한 번에 하나의 청크를 임베딩하는 대신 embed_batch()에 청크를 전달합니다.

대안을 선택해야 하는 경우

모델 차원 크기 속도 품질 (MTEB) 적합한 용도
potion-base-8M 256 30 MB 500x 50.03 기본값: 로컬, 빠름, GPU 불필요
potion-base-32M 256 120 MB 400x 52.46 더 높은 품질, 여전히 정적
potion-retrieval-32M 256 120 MB 400x 36.35 (검색) 검색에 최적화된 정적 모델
all-MiniLM-L6-v2 384 80 MB 1x 56.09 더 높은 품질, 여전히 로컬
nomic-embed-text-v1.5 768 270 MB 0.5x 62.28 최고의 로컬 품질
text-embedding-3-small 1536 API N/A 62.30 API 기반, 최고 품질

potion-base-32M 선택 시기: 정적 임베딩 계열을 벗어나지 않으면서 potion-base-8M보다 더 나은 품질을 원할 때 선택합니다. 2025년 1월에 출시된 이 모델은 baai/bge-base-en-v1.5에서 증류된 더 큰 어휘를 사용하며, 동일한 256차원 출력과 numpy 전용 의존성을 유지하면서 MTEB 평균 52.46(potion-base-8M 대비 5% 개선)을 달성합니다.18 4배 더 큰 모델 파일은 메모리 사용량을 증가시키지만, 임베딩 속도는 트랜스포머 모델보다 여전히 수 자릿수 더 빠릅니다.

potion-retrieval-32M 선택 시기: 주요 사용 사례가 검색(볼트 검색이 바로 이에 해당)일 때 선택합니다. 이 변형은 검색 작업에 특화되어 potion-base-32M에서 파인튜닝되었으며, MTEB 검색 벤치마크에서 기본 모델의 33.52 대비 36.35를 기록합니다.18 전체 MTEB 평균은 49.73으로 떨어지는데, 이는 파인튜닝이 범용 성능을 검색 특화 성능과 교환하기 때문입니다.

all-MiniLM-L6-v2 선택 시기: 검색 품질이 속도보다 중요하고 PyTorch가 설치되어 있을 때 선택합니다. 384차원 벡터는 256차원 벡터 대비 SQLite 데이터베이스 크기를 약 50% 증가시킵니다. 임베딩 속도는 M 시리즈 하드웨어에서 15,000개 파일 전체 재인덱싱 기준 1분 미만에서 약 10분으로 감소합니다.

nomic-embed-text-v1.5 선택 시기: 최상의 로컬 검색 품질이 필요하고 느린 인덱싱을 감수할 수 있을 때 선택합니다. 768차원 벡터는 데이터베이스 크기를 대략 3배로 늘립니다. PyTorch와 최신 CPU 또는 GPU가 필요합니다.

text-embedding-3-small 선택 시기: 네트워크 지연 시간과 프라이버시가 허용 가능한 트레이드오프일 때 선택합니다. API는 최고 품질의 임베딩을 생성하지만, 클라우드 의존성, 토큰당 비용($0.02/백만 토큰), 그리고 콘텐츠를 OpenAI 서버로 전송하는 문제가 발생합니다.

그 외 모든 경우에는 potion-base-8M을 유지하세요. 반복적 인덱싱(개발 중 재인덱싱)에서 속도 이점이 중요하고, numpy 전용 의존성으로 PyTorch 설치의 복잡성을 피할 수 있으며, 256차원 벡터로 데이터베이스를 컴팩트하게 유지할 수 있습니다.

양자화 및 차원 축소

Model2Vec v0.5.0 이상에서는 정밀도와 차원이 축소된 모델 로딩을 지원합니다.18 이는 제한된 하드웨어에 배포하거나 모델을 변경하지 않고 데이터베이스 크기를 줄이는 데 유용합니다:

from model2vec import StaticModel

# Load with int8 quantization (25% of original size)
model = StaticModel.from_pretrained("minishlab/potion-base-8M", quantize=True)

# Load with reduced dimensions (e.g., 128 instead of 256)
model = StaticModel.from_pretrained("minishlab/potion-base-8M", dimensionality=128)

양자화된 모델은 메모리 사용량의 극히 일부만으로 거의 동일한 검색 품질을 유지합니다. 차원 축소는 마트료시카(Matryoshka) 스타일의 절단을 따릅니다 — 처음 N개의 차원이 가장 많은 정보를 담고 있습니다. 256에서 128차원으로 축소하면 짧은 텍스트 검색에서 최소한의 품질 손실로 벡터 저장 공간을 절반으로 줄일 수 있습니다.

모델 해시 추적

인덱서는 모델 이름과 어휘 크기에서 파생된 해시를 저장합니다. 임베딩 모델을 변경하면, 인덱서가 다음 증분 실행 시 불일치를 감지하고 자동으로 전체 재인덱싱을 트리거합니다.

def _compute_model_hash(self):
    """Hash model name + vocab size for compatibility tracking."""
    key = f"{self._model_name}:{self._model.vocab_size}"
    return hashlib.sha256(key.encode()).hexdigest()[:16]

이렇게 하면 서로 다른 모델의 벡터가 동일한 데이터베이스에 혼합되는 것을 방지하여, 의미 없는 cosine similarity 점수가 생성되는 것을 막습니다.

실패 모드

모델 다운로드 실패. 첫 실행 시 Hugging Face에서 모델을 다운로드합니다. 다운로드가 실패하면(네트워크 문제, 기업 방화벽 등) 리트리버는 BM25 전용 모드로 폴백합니다. 첫 다운로드 후 모델은 로컬에 캐시됩니다.

차원 불일치. 데이터베이스를 초기화하지 않고 모델을 전환하면, 저장된 벡터와 새 임베딩의 차원이 다릅니다. 인덱서는 모델 해시를 통해 이를 감지하고 전체 재인덱싱을 트리거합니다. 해시 확인이 실패하면(적절한 해시가 없는 커스텀 모델의 경우) sqlite-vec이 차원이 일치하지 않는 KNN 쿼리에서 오류를 발생시킵니다.

대규모 볼트에서의 메모리 부담. 50,000개 이상의 청크를 단일 배치로 임베딩하면 상당한 메모리를 소비할 수 있습니다. 인덱서는 최대 메모리 사용량을 제한하기 위해 64개 단위의 배치로 처리합니다. 메모리가 여전히 문제라면 배치 크기를 줄이세요.


FTS5를 활용한 전체 텍스트 검색

SQLite의 FTS5 확장은 BM25 랭킹을 통한 전체 텍스트 검색을 제공합니다. FTS5는 hybrid retrieval 파이프라인의 키워드 검색 구성 요소입니다. 이 섹션에서는 FTS5 구성, BM25가 뛰어난 경우, 그리고 특정 실패 모드를 다룹니다.

FTS5 가상 테이블

CREATE VIRTUAL TABLE chunks_fts USING fts5(
    chunk_text,
    section,
    heading_context,
    content=chunks,
    content_rowid=id
);

콘텐츠 동기화 모드. content=chunks 매개변수는 FTS5가 텍스트의 복사본을 별도로 저장하지 않고 chunks 테이블을 직접 참조하도록 지시합니다. 이렇게 하면 저장 공간이 절반으로 줄어들지만, 청크가 삽입, 업데이트 또는 삭제될 때 FTS5를 수동으로 동기화해야 합니다.

컬럼. 세 개의 컬럼이 인덱싱됩니다: - chunk_text — 각 청크의 주요 콘텐츠 (BM25 가중치: 1.0) - section — H2 제목 텍스트 (BM25 가중치: 0.5) - heading_context — 노트 제목, 태그 및 메타데이터 (BM25 가중치: 0.3)

BM25 랭킹

BM25는 단어 빈도, 역문서 빈도, 문서 길이 정규화를 기반으로 문서의 순위를 매깁니다. FTS5의 bm25() 보조 함수는 컬럼별 가중치를 지정할 수 있습니다:

SELECT
    c.id, c.file_path, c.section, c.chunk_text,
    bm25(chunks_fts, 1.0, 0.5, 0.3) AS score
FROM chunks_fts
JOIN chunks c ON chunks_fts.rowid = c.id
WHERE chunks_fts MATCH ?
ORDER BY score
LIMIT 30;

컬럼 가중치(1.0, 0.5, 0.3)의 의미는 다음과 같습니다: - chunk_text에서의 키워드 일치가 점수에 가장 많이 기여합니다 - section(제목)에서의 일치는 절반만큼 기여합니다 - heading_context(제목, 태그)에서의 일치는 30%만큼 기여합니다

이러한 가중치는 조정할 수 있습니다. 볼트의 제목이 콘텐츠 품질을 잘 예측하는 설명적인 제목이라면 section 가중치를 높이세요. 태그가 포괄적이고 정확하다면 heading_context 가중치를 높이세요.

BM25가 뛰어난 경우

BM25는 정확한 식별자가 포함된 쿼리에서 뛰어난 성능을 발휘합니다:

  • 함수 이름: _rrf_fuse, embed_batch, get_stale_files
  • CLI 플래그: --incremental, --vault, --model
  • 설정 키: bm25_weight, max_tokens, batch_size
  • 오류 메시지: SQLITE_LOCKED, ConnectionRefusedError
  • 특정 전문 용어: PostToolUse, PreToolUse, AGENTS.md

이러한 쿼리의 경우 BM25는 정확한 일치 항목을 즉시 찾아냅니다. 벡터 검색은 의미적으로 관련된 콘텐츠를 반환하지만, 정확한 일치 항목보다 개념적 논의를 더 높은 순위로 매길 수 있습니다.

BM25가 실패하는 경우

BM25는 저장된 콘텐츠와 다른 용어를 사용하는 쿼리에서 실패합니다:

  • 쿼리: “how to handle authentication failures” → 볼트에는 “login error recovery”와 “session expiration handling”에 관한 노트가 있습니다. 키워드가 다르기 때문에 BM25는 일치하지 못합니다.
  • 쿼리: “what is the best way to manage state” → 볼트에는 “Redux store patterns”과 “context providers”에 관한 노트가 있습니다. “state management”가 구체적인 기술 이름으로 표현되어 있기 때문에 BM25가 놓칩니다.

BM25는 규모가 커지면 키워드 충돌에서도 실패합니다. 15,000개 파일로 구성된 볼트에서 “configuration”을 검색하면 거의 모든 프로젝트 노트가 configuration을 언급하기 때문에 수백 개의 노트가 일치합니다. 결과는 기술적으로 정확하지만 실질적으로는 쓸모없습니다 — 랭킹이 현재 쿼리와 관련된 “configuration” 노트가 어느 것인지 판별할 수 없기 때문입니다.

FTS5 토크나이저

FTS5는 기본적으로 ASCII 및 유니코드 텍스트를 처리하는 unicode61 토크나이저를 사용합니다. CJK(중국어, 일본어, 한국어) 콘텐츠가 많은 볼트의 경우 trigram 토크나이저를 고려하세요:

-- For CJK-heavy vaults
CREATE VIRTUAL TABLE chunks_fts USING fts5(
    chunk_text, section, heading_context,
    content=chunks, content_rowid=id,
    tokenize='trigram'
);

기본 unicode61 토크나이저는 단어 경계에서 분리하기 때문에, 단어 사이에 공백이 없는 언어에서는 제대로 작동하지 않습니다. trigram 토크나이저는 3글자마다 분리하여 부분 문자열 매칭을 가능하게 하지만, 인덱스 크기가 대략 3배 커지는 비용이 있습니다.

유지보수

FTS5는 기본 chunks 테이블이 변경될 때 명시적인 동기화가 필요합니다:

# After inserting chunks
cursor.execute("""
    INSERT INTO chunks_fts(chunks_fts)
    VALUES('rebuild')
""")

rebuild 명령은 콘텐츠 테이블에서 FTS5 인덱스를 재구성합니다. 대량 삽입(전체 리인덱스) 후에 실행하되, 개별 증분 업데이트 후에는 실행하지 마세요 — 개별 행 동기화에는 INSERT INTO chunks_fts(rowid, chunk_text, section, heading_context)를 사용하세요.


sqlite-vec를 활용한 벡터 검색

sqlite-vec 확장은 벡터 KNN(K-Nearest Neighbors) 검색을 SQLite에 도입합니다. 이 섹션에서는 sqlite-vec 구성, 노트에서 검색 가능한 벡터로의 임베딩(embedding) 파이프라인, 그리고 구체적인 쿼리 패턴을 다룹니다.

sqlite-vec 가상 테이블

CREATE VIRTUAL TABLE chunk_vecs USING vec0(
    id INTEGER PRIMARY KEY,
    embedding float[256]
);

vec0 모듈은 256차원 float 벡터를 압축된 바이너리 데이터로 저장합니다. id 컬럼은 chunks 테이블과 1:1로 매핑되어 벡터 결과와 청크 메타데이터 간의 조인을 가능하게 합니다.

임베딩 파이프라인

파이프라인은 노트에서 검색 가능한 벡터로 흐릅니다:

Note (.md file)
   Chunker: split at H2 boundaries
     Chunks (30-2000 chars each)
       Credential filter: scrub secrets
         Embedder: Model2Vec encode
           Vectors (256-dim float arrays)
             sqlite-vec: store as packed binary
               Ready for KNN queries

벡터 직렬화

Python의 struct 모듈은 sqlite-vec 저장을 위해 float 벡터를 직렬화합니다:

import struct

def _serialize_vector(vec):
    """Pack float list into binary for sqlite-vec."""
    return struct.pack(f"{len(vec)}f", *vec)

def _deserialize_vector(blob, dim=256):
    """Unpack binary blob to float list."""
    return list(struct.unpack(f"{dim}f", blob))

KNN 쿼리

벡터 검색 쿼리는 입력 쿼리를 임베딩한 후 cosine distance 기준으로 가장 가까운 K개의 청크를 찾습니다:

def _vector_search(self, query_text, limit=30):
    query_vec = self.embedder.embed_batch([query_text])[0]
    packed = _serialize_vector(query_vec)

    results = self.db.execute("""
        SELECT
            cv.id,
            cv.distance,
            c.file_path,
            c.section,
            c.chunk_text
        FROM chunk_vecs cv
        JOIN chunks c ON cv.id = c.id
        WHERE embedding MATCH ?
            AND k = ?
        ORDER BY distance
    """, [packed, limit]).fetchall()

    return results

sqlite-vec의 MATCH 연산자는 근사 최근접 이웃 검색을 수행합니다. k 매개변수는 반환할 결과 수를 제어합니다. distance 컬럼은 cosine distance를 포함합니다 (0 = 동일, 2 = 반대).

벡터 검색이 뛰어난 경우

벡터 검색은 특정 단어보다 개념이 더 중요한 쿼리에서 뛰어난 성능을 발휘합니다:

  • 쿼리: “how to handle authentication failures” → “login error recovery”에 관한 노트를 찾아냅니다 (동일한 의미 공간, 다른 키워드)
  • 쿼리: “what patterns exist for caching” → “memoization,” “Redis TTL strategies,” “HTTP cache headers”에 관한 노트를 찾아냅니다 (관련 개념, 다양한 용어)
  • 쿼리: “approaches to testing asynchronous code” → “pytest-asyncio fixtures,” “mock event loops,” “async test patterns”에 관한 노트를 찾아냅니다 (동일한 개념이 구현 세부사항으로 표현됨)

벡터 검색이 실패하는 경우

벡터 검색은 정확한 식별자에서 어려움을 겪습니다:

  • 쿼리: _rrf_fuse → “fusion algorithms”과 “rank merging”에 관한 노트를 반환하지만, 실제 함수 정의보다 개념적 논의를 더 높은 순위로 매길 수 있습니다
  • 쿼리: PostToolUse → 특정 훅 이름이 아닌 “tool lifecycle hooks”와 “post-execution handlers”에 관한 노트를 반환합니다

벡터 검색은 구조화된 데이터에서도 어려움을 겪습니다. JSON 설정 파일, YAML 블록, 코드 스니펫은 의미적 의미보다 구조적 패턴을 포착하는 임베딩을 생성합니다. "review": true가 포함된 JSON 파일은 코드 리뷰에 대한 산문적 논의와 다르게 임베딩됩니다.

우아한 성능 저하

sqlite-vec 로드에 실패하는 경우(확장 누락, 호환되지 않는 플랫폼, 손상된 라이브러리), 검색기는 BM25 전용 검색으로 폴백합니다:

class VectorIndex:
    def __init__(self, db_path):
        self.db = sqlite3.connect(db_path)
        self._vec_available = False
        try:
            self.db.enable_load_extension(True)
            self.db.load_extension("vec0")
            self._vec_available = True
        except Exception:
            pass  # BM25-only mode

    @property
    def vec_available(self):
        return self._vec_available

검색기는 벡터 쿼리를 시도하기 전에 vec_available을 확인합니다. 비활성화된 경우 모든 검색은 BM25만 사용하며, RRF 융합 단계는 건너뜁니다.


Reciprocal Rank Fusion (RRF)

RRF는 점수 보정 없이 두 개의 순위 목록을 병합합니다. 이 섹션에서는 알고리즘, 실제 쿼리 추적 예시, k 파라미터 튜닝, 그리고 RRF를 다른 대안보다 선택하는 이유를 다룹니다. 편집 가능한 순위, 시나리오 프리셋, 시각적 아키텍처 탐색기가 포함된 인터랙티브 계산기는 hybrid retriever 상세 분석을 참조하세요.

알고리즘

RRF는 각 문서에 각 목록에서의 순위 위치만을 기반으로 점수를 부여합니다:

score(d) = Σ (weight_i / (k + rank_i))

각 변수의 의미: - k는 평활 상수입니다 (60, Cormack et al.1 기준) - rank_i는 결과 목록 i에서 해당 문서의 1부터 시작하는 순위입니다 - weight_i는 각 목록에 대한 선택적 가중치 배수입니다 (기본값 1.0)

여러 목록에서 높은 순위를 차지하는 문서는 더 높은 융합 점수를 받습니다. 하나의 목록에만 나타나는 문서는 해당 단일 소스의 점수만 받습니다.

대안 대비 RRF를 선택하는 이유

가중 선형 결합(Weighted linear combination)은 BM25 점수와 cosine similarity 거리를 보정해야 합니다. BM25 점수는 상한이 없으며 코퍼스 크기에 따라 달라집니다. cosine similarity 거리는 [0, 2] 범위로 제한됩니다. 이 둘을 결합하려면 정규화가 필요하며, 정규화 파라미터는 데이터셋에 따라 달라집니다. RRF는 점수 산출 방식과 관계없이 항상 1부터 시작하는 정수인 순위 위치만 사용합니다.

학습 기반 융합 모델(Learned fusion models)은 라벨링된 학습 데이터 — 쿼리-문서 관련성 쌍이 필요합니다. 개인 지식 베이스에서는 이러한 학습 데이터가 존재하지 않습니다. 유용한 모델을 학습시키려면 수백 개의 쿼리-문서 쌍을 수동으로 평가해야 합니다. RRF는 학습 데이터 없이도 작동합니다.

Condorcet 투표 방식(Borda count, Schulze method)은 이론적으로 우아하지만 구현과 튜닝이 더 복잡합니다. 원래 RRF 논문에서는 TREC 평가 데이터에서 RRF가 Condorcet 방식보다 우수한 성능을 보여주었습니다.1

실제 융합 과정

쿼리: “how does the review aggregator handle disagreements”

BM25는 review-aggregator.py를 3위로 순위 매깁니다 (“review,” “aggregator,” “disagreements”에 대한 정확한 키워드 매칭). 하지만 두 개의 설정 파일이 더 높은 순위를 차지합니다 (“review”가 더 두드러지게 매칭됨). 벡터 검색은 동일한 청크를 1위로 순위 매깁니다 (충돌 해결에 대한 의미적 매칭). RRF 융합 후:

청크 BM25 Vec 융합 점수
review-aggregator.py “Disagreement Resolution” #3 #1 0.0323
code-review-patterns.md “Multi-Reviewer” #4 #2 0.0317
deliberation-config.json “Review Weights” #1 0.0164

양쪽 목록에서 높은 순위를 차지하는 청크가 상위로 올라옵니다. 하나의 목록에만 나타나는 청크는 단일 소스 점수만 받아 양쪽 목록에 등장하는 결과보다 아래로 밀려납니다. 실제 의견 불일치 해결 로직이 최상위를 차지하는 이유는 두 방식 모두가 이를 찾아냈기 때문입니다 — BM25는 키워드를 통해, 벡터 검색은 의미론을 통해 찾아냈습니다.

순위별 RRF 수학 계산이 포함된 전체 단계별 추적과 다양한 k 값 실험은 인터랙티브 RRF 계산기에서 확인할 수 있습니다.

구현

RRF_K = 60

def _rrf_fuse(self, bm25_results, vec_results,
              bm25_weight=1.0, vec_weight=1.0):
    """Fuse BM25 and vector results using Reciprocal Rank Fusion."""
    scores = {}

    for rank, r in enumerate(bm25_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {
                "rrf_score": 0.0,
                "file_path": r["file_path"],
                "section": r["section"],
                "chunk_text": r["chunk_text"],
                "bm25_rank": None,
                "vec_rank": None,
            }
        scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
        scores[cid]["bm25_rank"] = rank

    for rank, r in enumerate(vec_results, start=1):
        cid = r["id"]
        if cid not in scores:
            scores[cid] = {
                "rrf_score": 0.0,
                "file_path": r["file_path"],
                "section": r["section"],
                "chunk_text": r["chunk_text"],
                "bm25_rank": None,
                "vec_rank": None,
            }
        scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
        scores[cid]["vec_rank"] = rank

    fused = sorted(
        scores.values(),
        key=lambda x: x["rrf_score"],
        reverse=True,
    )
    return fused

k 튜닝

k 상수는 상위 순위 결과와 하위 순위 결과에 얼마나 가중치를 부여할지를 제어합니다:

  • 낮은 k (예: 10): 상위 순위 결과가 지배적입니다. 1위 점수 1/11 = 0.091, 10위 점수 1/20 = 0.050 (1.8배 차이). 개별 랭커가 최상위 결과를 정확하게 찾아낸다고 신뢰할 때 적합합니다.
  • 기본 k (60): 균형 잡힌 설정입니다. 1위 점수 1/61 = 0.0164, 10위 점수 1/70 = 0.0143 (1.15배 차이). 순위 차이가 압축되어 여러 목록에 등장하는 것에 더 많은 가중치를 부여합니다.
  • 높은 k (예: 200): 순위 위치보다 양쪽 목록에 등장하는 것이 훨씬 더 중요해집니다. 1위 점수 1/201, 10위 점수 1/210 — 거의 동일합니다. 개별 랭커의 순위가 불안정하지만 교차 목록 일치가 신뢰할 수 있을 때 사용합니다.

k=60으로 시작하세요. 원래 RRF 논문에서 이 값이 다양한 TREC 데이터셋에서 안정적임을 확인했습니다. 자신의 쿼리 분포에서 실패 사례를 측정한 후에만 튜닝하세요.

동점 처리

두 청크의 RRF 점수가 동일한 경우(드물지만, 하나의 목록에서 동일한 순위를 갖고 다른 목록에는 나타나지 않을 때 발생 가능), 다음 기준으로 동점을 처리합니다:

  1. 하나의 목록에만 나타나는 청크보다 양쪽 목록에 모두 나타나는 청크를 우선합니다
  2. 양쪽 목록에 모두 있는 청크 중에서는 합산 순위가 더 낮은 것을 우선합니다
  3. 하나의 목록에만 있는 청크 중에서는 해당 목록에서 순위가 더 낮은 것을 우선합니다

완전한 검색 파이프라인

이 섹션에서는 쿼리가 입력에서 출력까지 전체 파이프라인을 거치는 과정을 추적합니다: BM25 검색, 벡터 검색, RRF 융합, 토큰 예산 잘라내기, 그리고 컨텍스트 조립.

엔드투엔드 흐름

User query: "PostToolUse hook for context compression"
  │
  ├─ BM25 Search (FTS5)
  │    → MATCH "PostToolUse hook context compression"
  │    → Top 30 results ranked by BM25 score
  │    → 12ms
  │
  ├─ Vector Search (sqlite-vec)
  │    → Embed query with Model2Vec
  │    → KNN k=30 on chunk_vecs
  │    → Top 30 results ranked by cosine distance
  │    → 8ms
  │
  └─ RRF Fusion
       → Merge 60 candidates (may overlap)
       → Score by rank position
       → Top 10 results
       → 3ms
       │
       └─ Token Budget
            → Truncate to max_tokens (default 4000)
            → Estimate at 4 chars per token
            → Return results with metadata
            → <1ms

총 지연 시간: ~23ms — Apple M3 Pro 하드웨어에서 49,746개 청크 데이터베이스 기준입니다.

검색 API

class HybridRetriever:
    def search(self, query, limit=10, max_tokens=4000,
               bm25_weight=1.0, vec_weight=1.0):
        """
        Search the vault using hybrid BM25 + vector retrieval.

        Args:
            query: Search query text
            limit: Maximum results to return
            max_tokens: Token budget for total result text
            bm25_weight: Weight for BM25 results in RRF
            vec_weight: Weight for vector results in RRF

        Returns:
            List of SearchResult with file_path, section,
            chunk_text, rrf_score, bm25_rank, vec_rank
        """
        # BM25 search
        bm25_results = self._bm25_search(query, limit=30)

        # Vector search (if available)
        if self.index.vec_available:
            vec_results = self._vector_search(query, limit=30)
            fused = self._rrf_fuse(
                bm25_results, vec_results,
                bm25_weight, vec_weight,
            )
        else:
            fused = bm25_results  # BM25-only fallback

        # Token budget truncation
        results = []
        token_count = 0
        for r in fused[:limit]:
            chunk_tokens = len(r["chunk_text"]) // 4
            if token_count + chunk_tokens > max_tokens:
                break
            results.append(r)
            token_count += chunk_tokens

        return results

토큰 예산 잘라내기

max_tokens 매개변수는 검색기가 AI 도구가 사용할 수 있는 양보다 더 많은 컨텍스트를 반환하는 것을 방지합니다. 추정치는 토큰당 4자를 사용합니다(영어 산문에 대한 합리적인 근사값). 결과는 탐욕적으로 잘라냅니다: 순위 순서대로 결과를 추가하다가 예산이 소진되면 중단합니다.

이는 보수적인 전략입니다. 더 정교한 접근 방식은 결과별 품질 점수를 고려하여 길고 품질이 낮은 결과보다 짧고 품질이 높은 결과를 선호할 수 있습니다. 탐욕적 접근 방식은 더 단순하며, RRF 순위가 이미 관련성 순으로 결과를 정렬하기 때문에 실제로 잘 작동합니다.

데이터베이스 스키마 (전체)

-- Chunk content and metadata
CREATE TABLE chunks (
    id INTEGER PRIMARY KEY,
    file_path TEXT NOT NULL,
    section TEXT NOT NULL,
    chunk_text TEXT NOT NULL,
    heading_context TEXT DEFAULT '',
    mtime_ns INTEGER NOT NULL,
    embedded_at REAL NOT NULL
);

CREATE INDEX idx_chunks_file ON chunks(file_path);
CREATE INDEX idx_chunks_mtime ON chunks(mtime_ns);

-- FTS5 for BM25 search (content-synced to chunks table)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
    chunk_text, section, heading_context,
    content=chunks, content_rowid=id
);

-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
    id INTEGER PRIMARY KEY,
    embedding float[256]
);

-- Model metadata for compatibility tracking
CREATE TABLE model_meta (
    key TEXT PRIMARY KEY,
    value TEXT
);

점진적 성능 저하 경로

Full pipeline:     BM25 + Vector + RRF    Best results
No sqlite-vec:     BM25 only              Good results (no semantic)
No model download:  BM25 only              Good results (no semantic)
No FTS5:           Vector only             Decent results (no keyword)
No database:       Error                   Prompt user to run indexer

검색기는 초기화 시 사용 가능한 기능을 확인하고 쿼리 전략을 조정합니다. 구성 요소가 누락되면 품질은 저하되지만 오류가 발생하지는 않습니다. 유일한 치명적 실패는 데이터베이스 파일이 없는 경우입니다.

프로덕션 통계

16,894개 파일, 49,746개 청크, 83 MB SQLite 데이터베이스, Apple M3 Pro에서 측정한 결과입니다:

지표
총 파일 수 16,894
총 청크 수 49,746
데이터베이스 크기 83 MB
BM25 쿼리 지연 시간 (p50) 12ms
벡터 쿼리 지연 시간 (p50) 8ms
RRF 융합 지연 시간 3ms
엔드투엔드 검색 지연 시간 (p50) 23ms
전체 재인덱싱 시간 ~4분
증분 재인덱싱 시간 <10초
임베딩(embedding) 모델 potion-base-8M (256-dim)
BM25 후보 풀 30
벡터 후보 풀 30
기본 결과 제한 10
기본 토큰 예산 4,000 토큰

콘텐츠 해싱과 변경 감지

인덱서는 마지막 인덱스 실행 이후 어떤 파일이 변경되었는지 알아야 합니다. 이 섹션에서는 변경 감지 메커니즘과 해싱 전략을 다룹니다.

파일 수정 시간 비교

인덱서는 chunks 테이블의 모든 청크에 대해 mtime_ns(나노초 단위의 파일 수정 시간)를 저장합니다. 증분 실행 시 인덱서는 다음을 수행합니다:

  1. 허용된 폴더 내의 모든 .md 파일에 대해 볼트를 스캔합니다
  2. 파일 시스템에서 각 파일의 mtime_ns를 읽습니다
  3. 데이터베이스에 저장된 mtime_ns와 비교합니다
  4. 세 가지 범주를 식별합니다:
  5. 새 파일: 파일 시스템에는 존재하지만 데이터베이스에는 없는 경로
  6. 변경된 파일: 양쪽 모두에 존재하지만 mtime_ns가 다른 경로
  7. 삭제된 파일: 데이터베이스에는 존재하지만 파일 시스템에는 없는 경로
def get_stale_files(self, vault_mtimes):
    """Find files whose mtime changed or are new."""
    stored = dict(self.db.execute(
        "SELECT DISTINCT file_path, mtime_ns FROM chunks"
    ).fetchall())

    stale = []
    for path, mtime in vault_mtimes.items():
        if path not in stored or stored[path] != mtime:
            stale.append(path)
    return stale

def get_deleted_files(self, vault_paths):
    """Find files in database that no longer exist in vault."""
    stored_paths = set(r[0] for r in self.db.execute(
        "SELECT DISTINCT file_path FROM chunks"
    ).fetchall())
    return stored_paths - set(vault_paths)

mtime을 사용하는 이유 — 콘텐츠 해시가 아닌 이유

콘텐츠 해싱(파일 내용의 SHA-256)은 mtime 비교보다 더 신뢰할 수 있습니다 — 파일이 변경 없이 터치된 경우(예: git checkout으로 원래 mtime이 복원된 경우)를 감지할 수 있기 때문입니다. 그러나 해싱은 모든 증분 실행마다 모든 파일을 읽어야 합니다. 16,894개 파일의 경우 파일 내용을 읽는 데 2~3초가 걸립니다. 파일 시스템에서 mtime을 읽는 데는 100ms 미만이 소요됩니다.

트레이드오프: mtime 비교는 가끔 변경되지 않은 파일의 불필요한 재인덱싱을 유발하지만(거짓 양성), 실제 변경을 놓치는 일은 절대 없습니다. 거짓 양성은 실행당 몇 번의 추가 임베딩 호출 비용이 발생할 뿐입니다. 속도 차이(100ms 대 3초)는 모든 AI 상호작용마다 실행되는 시스템에서 mtime이 실용적인 선택이 되게 합니다.

삭제 처리

볼트에서 파일이 삭제되면 인덱서는 데이터베이스에서 해당 파일의 모든 청크를 제거합니다:

def remove_file(self, file_path):
    """Remove all chunks and vectors for a file."""
    chunk_ids = [r[0] for r in self.db.execute(
        "SELECT id FROM chunks WHERE file_path = ?",
        [file_path],
    ).fetchall()]

    for cid in chunk_ids:
        self.db.execute(
            "DELETE FROM chunk_vecs WHERE id = ?", [cid]
        )
    self.db.execute(
        "DELETE FROM chunks WHERE file_path = ?",
        [file_path],
    )

FTS5 content-sync 테이블은 제거된 각 행에 대해 INSERT INTO chunks_fts(chunks_fts, rowid, ...) VALUES('delete', ?, ...)를 통한 명시적 삭제가 필요합니다. 인덱서는 파일 제거 프로세스의 일부로 이를 처리합니다.


증분 리인덱싱 vs 전체 리인덱싱

인덱서는 두 가지 모드를 지원합니다: 증분(빠름, 일상적 사용)과 전체(느림, 간헐적 사용). 이 섹션에서는 각 모드의 사용 시기, 멱등성 보장, 그리고 손상 복구에 대해 다룹니다.

증분 리인덱싱

사용 시기: 노트 편집 후 일상적인 인덱싱. 기본 모드입니다.

동작 방식: 1. vault에서 파일 변경 사항 스캔 (mtime 비교) 2. 삭제된 파일의 청크 제거 3. 변경된 파일의 재청킹 및 재임베딩 4. 새 파일의 새 청크 삽입 5. FTS5 인덱스 동기화

일반적인 소요 시간: 16,000개 파일 vault에서 하루 편집분 기준 10초 미만.

python index_vault.py --incremental

전체 리인덱싱

사용 시기: - 임베딩 모델 변경 후 (모델 해시 불일치 감지 시) - 스키마 마이그레이션 후 (새 컬럼, 변경된 인덱스) - 데이터베이스 손상 후 (무결성 검사 실패 시) - 증분 인덱싱이 예상치 못한 결과를 생성할 때

동작 방식: 1. 기존 데이터 전체 삭제 (청크, 벡터, FTS5 항목) 2. 전체 vault 스캔 3. 모든 파일 청킹 4. 모든 청크 임베딩 5. FTS5 인덱스 처음부터 구축

일반적인 소요 시간: Apple M3 Pro에서 16,894개 파일 기준 약 4분.

python index_vault.py --full

멱등성

두 모드 모두 멱등성을 보장합니다: 같은 명령을 두 번 실행해도 동일한 결과가 생성됩니다. 인덱서는 새 청크를 삽입하기 전에 해당 파일의 기존 청크를 삭제하므로, 이미 최신 상태인 데이터베이스에 증분 인덱싱을 재실행하면 변경 사항이 발생하지 않습니다. 전체 인덱싱을 재실행하면 동일한 데이터베이스가 생성됩니다.

손상 복구

SQLite 데이터베이스가 손상된 경우 (쓰기 중 전원 차단, 디스크 오류, 트랜잭션 중 프로세스 강제 종료):

# Check integrity
sqlite3 vectors.db "PRAGMA integrity_check;"

# If corruption detected, full reindex rebuilds from source files
python index_vault.py --full

원본 데이터의 출처는 항상 데이터베이스가 아닌 vault 파일입니다. 데이터베이스는 언제든지 재구축할 수 있는 파생 산출물입니다. 이것은 핵심적인 설계 특성입니다: 데이터베이스를 백업할 필요가 없습니다.

--incremental 플래그

인덱서가 --incremental로 실행될 때:

  1. 모델 해시 확인. 저장된 모델 해시와 현재 모델을 비교합니다. 다를 경우 자동으로 전체 리인덱싱 모드로 전환하고 사용자에게 경고합니다.
  2. 파일 스캔. 허용된 폴더를 순회하며 파일 경로와 mtime을 수집합니다.
  3. 변경 감지. 저장된 데이터와 비교합니다.
  4. 배치 처리. 변경된 파일을 64개씩 배치로 재청킹하고 재임베딩합니다.
  5. 진행 상황 보고. 처리된 파일 수와 경과 시간을 출력합니다.
  6. 정상 종료. SIGINT를 처리하여 현재 파일 처리를 완료한 후 중단합니다.

인증 정보 필터링과 데이터 경계

개인 노트에는 비밀 정보가 포함되어 있습니다: API 키, bearer 토큰, 데이터베이스 연결 문자열, 디버깅 세션 중 붙여넣은 개인 키 등. 인증 정보 필터는 이러한 정보가 검색 인덱스에 진입하는 것을 방지합니다.

문제점

OAuth 통합 디버깅에 관한 노트에 다음과 같은 내용이 포함될 수 있습니다:

The token was: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
I used this curl command:
  curl -H "Authorization: Bearer sk-ant-api03-abc123..."

필터링 없이는 JWT와 API 키 모두 청킹, 임베딩되어 데이터베이스에 저장됩니다. “authentication”을 검색하면 실제 비밀 정보가 포함된 청크가 반환됩니다. 더 심각한 것은, 리트리버가 MCP를 통해 AI 도구에 결과를 전달하면 비밀 정보가 AI의 컨텍스트 윈도우에 나타나고 잠재적으로 도구의 로그에도 기록될 수 있다는 것입니다.

패턴 기반 필터링

인증 정보 필터는 저장 전 모든 청크에 대해 실행되며, 25개의 벤더별 패턴과 일반 패턴을 매칭합니다:

벤더별 패턴:

패턴 예시 정규식
OpenAI API 키 sk-... sk-[a-zA-Z0-9_-]{20,}
Anthropic API 키 sk-ant-api03-... sk-ant-api\d{2}-[a-zA-Z0-9_-]{20,}
GitHub PAT ghp_... gh[ps]_[a-zA-Z0-9]{36,}
AWS Access Key AKIA... AKIA[0-9A-Z]{16}
Stripe 키 sk_live_... [sr]k_(live\|test)_[a-zA-Z0-9]{24,}
Cloudflare 토큰 ... 다양한 패턴

일반 패턴:

패턴 감지 방식
JWT 토큰 eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+
Bearer 토큰 Bearer\s+[a-zA-Z0-9_\-\.]+
개인 키 -----BEGIN (RSA\|EC\|OPENSSH) PRIVATE KEY-----
높은 엔트로피 base64 4.5 bits/char 이상의 엔트로피를 가진 40자 이상 문자열
비밀번호 할당 password\s*[:=]\s*["'][^"']+["']

필터 구현

def clean_content(text):
    """Scrub credentials from text before indexing."""
    result = ScanResult(is_clean=True, match_count=0, patterns=[])

    for pattern in CREDENTIAL_PATTERNS:
        matches = pattern.regex.findall(text)
        if matches:
            text = pattern.regex.sub(
                f"[REDACTED:{pattern.name}]", text
            )
            result.is_clean = False
            result.match_count += len(matches)
            result.patterns.append(pattern.name)

    return text, result

핵심 설계 결정:

  1. 임베딩 전 필터링. 정제된 텍스트가 임베딩됩니다. 벡터 표현은 인증 정보 패턴을 절대 인코딩하지 않습니다. “API key”를 검색하면 API 키 관리를 논의하는 노트가 반환되며, 실제 키가 포함된 노트는 반환되지 않습니다.

  2. 제거가 아닌 대체. [REDACTED:pattern-name] 토큰은 주변 텍스트의 의미적 맥락을 보존합니다. 임베딩은 인증 정보 자체를 인코딩하지 않으면서 “인증 정보와 유사한 무언가가 여기 있었다”는 것을 포착합니다.

  3. 값이 아닌 패턴 로깅. 필터는 어떤 패턴이 매칭되었는지 기록하지만 (예: “oauth-debug.md에서 2개의 인증 정보를 제거함 [jwt, bearer-token]”), 인증 정보 값 자체는 절대 기록하지 않습니다.

경로 기반 제외

.indexignore 파일은 경로별 대략적인 제외 기능을 제공합니다. 인증 정보 필터는 인덱싱된 파일 내에서 세밀한 정제 기능을 제공합니다. 두 가지 모두 필요합니다:

  • .indexignore는 민감한 콘텐츠가 포함되어 있는 것이 확실한 전체 폴더용 (건강 기록, 재무 기록, 경력 문서)
  • 인증 정보 필터는 그 외 인덱싱 가능한 콘텐츠에 실수로 포함된 비밀 정보용

데이터 분류

다양한 콘텐츠가 포함된 vault의 경우, 민감도에 따라 노트를 분류하는 것을 권장합니다:

수준 예시 인덱싱 여부 필터링 여부
공개 블로그 초안, 기술 노트
내부 프로젝트 계획, 아키텍처 결정
민감 급여 데이터, 건강 기록 아니오 (.indexignore) 해당 없음
제한 인증 정보, 개인 키 아니오 (.indexignore) 해당 없음

MCP 서버 아키텍처

Model Context Protocol (MCP) 서버는 리트리버를 AI 에이전트가 호출할 수 있는 도구로 노출합니다. 이 섹션에서는 서버 설계, 기능 범위, 권한 경계를 다룹니다.

프로토콜 선택: STDIO vs HTTP

MCP는 두 가지 전송 모드를 지원합니다:

STDIO — AI 도구가 MCP 서버를 자식 프로세스로 생성하고 stdin/stdout을 통해 통신합니다. 로컬 도구의 표준 모드입니다. Claude Code, Codex CLI, Cursor 모두 STDIO MCP 서버를 지원합니다.

{
  "mcpServers": {
    "obsidian": {
      "command": "python",
      "args": ["/path/to/obsidian_mcp.py"],
      "env": {
        "VAULT_PATH": "/path/to/vault",
        "DB_PATH": "/path/to/vectors.db"
      }
    }
  }
}

HTTP — MCP 서버가 독립 실행형 HTTP 서비스로 실행됩니다. 원격 접근, 다중 클라이언트 설정, 또는 볼트가 공유 서버에 있는 팀 구성에 유용합니다.

{
  "mcpServers": {
    "obsidian": {
      "url": "http://localhost:3333/mcp"
    }
  }
}

권장 사항: 개인 볼트에는 STDIO를 사용하세요. 더 간단하고, 더 안전하며(네트워크 노출 없음), 서버 수명 주기가 AI 도구에 의해 관리됩니다. 여러 도구나 여러 머신이 동일한 볼트에 동시 접근해야 하는 경우에만 HTTP를 사용하세요.

MCP 사양 변화. 2025년 6월 MCP 사양에서는 OAuth 기반 인가, 구조화된 도구 출력(타입이 지정된 반환 스키마), 엘리시테이션(서버 주도 사용자 프롬프트)이 추가되었습니다.16 다음 사양 릴리스(잠정적으로 2026년 6월)에서는 장기 실행 작업을 위한 비동기 작업, 기본 전송 모드로서의 무상태 요청 처리, .well-known URL을 통한 서버 검색이 제안되어 있습니다.16 개인 볼트 서버의 경우 STDIO가 가장 간단한 경로로 남아 있습니다. 사양 변경은 주로 다중 테넌트 라우팅과 로드 밸런싱을 사용하는 엔터프라이즈 HTTP 배포에 영향을 미칩니다. 전송 선택에 영향을 미치는 업데이트는 MCP 로드맵을 확인하세요.

기능 설계

MCP 서버는 최소한의 도구 세트를 노출해야 합니다:

search — 핵심 도구입니다. hybrid 검색을 실행하고 순위가 매겨진 결과를 반환합니다.

{
  "name": "obsidian_search",
  "description": "Search the Obsidian vault using hybrid BM25 + vector retrieval",
  "parameters": {
    "query": { "type": "string", "description": "Search query" },
    "limit": { "type": "integer", "default": 5 },
    "max_tokens": { "type": "integer", "default": 2000 }
  }
}

read_note — 경로를 기반으로 특정 노트의 전체 내용을 읽습니다. 에이전트가 검색 결과의 전체 컨텍스트를 확인하고자 할 때 유용합니다.

{
  "name": "obsidian_read_note",
  "description": "Read the full content of a note by file path",
  "parameters": {
    "file_path": { "type": "string", "description": "Relative path within vault" }
  }
}

list_notes — 필터(폴더, 태그, 유형, 날짜 범위)에 맞는 노트를 나열합니다. 에이전트가 특정 쿼리 없이 탐색할 때 유용합니다.

{
  "name": "obsidian_list_notes",
  "description": "List notes matching filters",
  "parameters": {
    "folder": { "type": "string", "description": "Folder path within vault" },
    "tag": { "type": "string", "description": "Tag to filter by" },
    "limit": { "type": "integer", "default": 20 }
  }
}

get_context — 검색을 실행하고 결과를 대화에 주입하기 적합한 컨텍스트 블록으로 포맷하는 편의 도구입니다.

{
  "name": "obsidian_get_context",
  "description": "Get formatted context from vault for a topic",
  "parameters": {
    "topic": { "type": "string", "description": "Topic to get context for" },
    "max_tokens": { "type": "integer", "default": 2000 }
  }
}

권한 경계

MCP 서버는 엄격한 경계를 적용해야 합니다:

  1. 읽기 전용. 서버는 볼트와 인덱스 데이터베이스를 읽기만 합니다. 노트를 생성, 수정, 삭제하지 않습니다. 쓰기 작업(새 노트 캡처)은 MCP 서버가 아닌 별도의 훅이나 스킬에서 처리됩니다.

  2. 볼트 범위 제한. 서버는 설정된 볼트 경로 내의 파일만 읽습니다. 경로 순회 시도(../../etc/passwd)는 반드시 거부해야 합니다.

  3. 자격 증명 필터링된 출력. 데이터베이스에 사전 필터링된 콘텐츠가 포함되어 있더라도, 심층 방어 차원에서 출력 시 자격 증명 필터링을 적용합니다.

  4. 토큰 제한 응답. 모든 도구 응답에 max_tokens를 적용하여 AI 도구가 과도하게 큰 컨텍스트 블록을 수신하는 것을 방지합니다.

오류 처리

MCP 도구는 AI 도구가 복구할 수 있도록 구조화된 오류 메시지를 반환해야 합니다:

def search(self, query, limit=5, max_tokens=2000):
    if not self.db_path.exists():
        return {
            "error": "Index database not found. Run the indexer first.",
            "suggestion": "python index_vault.py --full"
        }

    results = self.retriever.search(query, limit, max_tokens)

    if not results:
        return {
            "results": [],
            "message": f"No results found for '{query}'. Try broader terms."
        }

    return {
        "results": [
            {
                "file_path": r["file_path"],
                "section": r["section"],
                "text": r["chunk_text"],
                "score": round(r["rrf_score"], 4),
            }
            for r in results
        ],
        "count": len(results),
        "query": query,
    }

Claude Code 통합

Claude Code는 Obsidian 검색 시스템의 주요 소비자입니다. 이 섹션에서는 MCP 설정, 훅 통합, obsidian_bridge.py 패턴을 다룹니다.

MCP 설정

Obsidian MCP 서버를 ~/.claude/settings.json에 추가합니다:

{
  "mcpServers": {
    "obsidian": {
      "command": "python",
      "args": ["/path/to/obsidian_mcp.py"],
      "env": {
        "VAULT_PATH": "/absolute/path/to/vault",
        "DB_PATH": "/absolute/path/to/vectors.db"
      }
    }
  }
}

설정을 추가한 후 Claude Code를 재시작합니다. MCP 서버가 자식 프로세스로 시작됩니다. 실행 중인지 확인하세요:

> What tools do you have from the obsidian MCP server?

Claude Code가 사용 가능한 도구(obsidian_search, obsidian_read_note 등)를 나열해야 합니다.

훅 통합

훅은 정의된 수명 주기 지점에서 Claude Code의 동작을 확장합니다. Obsidian 통합에 관련된 훅은 두 가지입니다:

PreToolUse 훅 — 에이전트가 도구 호출을 처리하기 전에 볼트를 쿼리합니다. 관련 컨텍스트를 자동으로 주입합니다.

#!/bin/bash
# ~/.claude/hooks/pre-tool-use/obsidian-context.sh
# Automatically inject vault context before tool execution

TOOL_NAME="$1"
PROMPT="$2"

# Only inject context for code-related tools
case "$TOOL_NAME" in
    Edit|Write|Bash)
        # Query the vault
        CONTEXT=$(python /path/to/retriever.py search "$PROMPT" --limit 3 --max-tokens 1500)
        if [ -n "$CONTEXT" ]; then
            echo "---"
            echo "Relevant vault context:"
            echo "$CONTEXT"
            echo "---"
        fi
        ;;
esac

PostToolUse 훅 — 중요한 도구 출력을 향후 검색을 위해 볼트에 캡처합니다.

#!/bin/bash
# ~/.claude/hooks/post-tool-use/capture-insight.sh
# Capture significant outputs to vault (selective)

TOOL_NAME="$1"
OUTPUT="$2"

# Only capture substantial outputs
if [ ${#OUTPUT} -gt 500 ]; then
    python /path/to/capture.py --text "$OUTPUT" --source "claude-code-$TOOL_NAME"
fi

obsidian_bridge.py 패턴

브리지 모듈은 훅과 스킬이 호출할 수 있는 Python API를 제공합니다:

# obsidian_bridge.py
from retriever import HybridRetriever

_retriever = None

def get_retriever():
    global _retriever
    if _retriever is None:
        _retriever = HybridRetriever(
            db_path="/path/to/vectors.db",
            vault_path="/path/to/vault",
        )
    return _retriever

def search_vault(query, limit=5, max_tokens=2000):
    """Search vault and return formatted context."""
    retriever = get_retriever()
    results = retriever.search(query, limit, max_tokens)

    if not results:
        return ""

    lines = ["## Vault Context\n"]
    for r in results:
        lines.append(f"**{r['file_path']}** — {r['section']}")
        lines.append(f"> {r['chunk_text'][:500]}")
        lines.append("")

    return "\n".join(lines)

/capture 스킬

볼트에 인사이트를 캡처하기 위한 Claude Code 스킬입니다:

/capture "OAuth token rotation requires both access and refresh token invalidation"
  --domain security
  --tags oauth,tokens

이 스킬은 적절한 frontmatter가 포함된 새 노트를 00-inbox/에 생성하고 증분 재인덱싱을 트리거하여 새 노트가 즉시 검색 가능하도록 합니다.

컨텍스트 윈도우 관리

통합 시 Claude Code의 컨텍스트 윈도우를 고려해야 합니다:

  • 쿼리당 주입되는 컨텍스트를 1,500-2,000 토큰으로 제한하세요. 이를 초과하면 에이전트의 작업 메모리와 경쟁하게 됩니다.
  • 출처 표시를 포함하세요. 에이전트가 출처를 참조할 수 있도록 항상 파일 경로와 섹션 제목을 포함합니다.
  • 청크 텍스트를 잘라내세요. 긴 청크는 완전히 생략하는 대신 ...로 잘라내야 합니다. 처음 300-500자에 핵심 정보가 포함되어 있는 경우가 대부분입니다.
  • 모든 도구 호출에 주입하지 마세요. PreToolUse 훅은 호출되는 도구에 따라 선택적으로 컨텍스트를 주입해야 합니다. 읽기 작업에는 볼트 컨텍스트가 필요하지 않습니다. 쓰기 및 편집 작업에서 효과적입니다.

Codex CLI 통합

Codex CLI는 config.toml을 통해 MCP 서버에 연결합니다. 통합 패턴은 설정 구문과 지시 전달 방식에서 Claude Code와 다릅니다.

MCP 설정

.codex/config.toml 또는 ~/.codex/config.toml에 추가합니다:

[mcp_servers.obsidian]
command = "python"
args = ["/path/to/obsidian_mcp.py"]

[mcp_servers.obsidian.env]
VAULT_PATH = "/absolute/path/to/vault"
DB_PATH = "/absolute/path/to/vectors.db"

AGENTS.md 패턴

Codex CLI는 프로젝트 수준의 지시사항을 위해 AGENTS.md를 읽습니다. 볼트 검색 가이드를 포함하세요:

## 사용 가능한 도구

### Obsidian 볼트 (MCP: obsidian)
`obsidian_search` 도구를 사용하여 지식 기반에서 관련 컨텍스트를 검색할 수 있습니다.
다음과 같은 경우에 볼트를 검색하세요:
- 개념이나 패턴에 대한 배경 정보가 필요할 때
- 이전 결정 사항이나 근거를 확인할 때
- 구현을 위한 참조 자료가 필요할 때

쿼리 예시:
- "authentication patterns in FastAPI"
- "how does the review aggregator work"
- "sqlite-vec configuration"

Claude Code와의 차이점

기능 Claude Code Codex CLI
MCP 설정 settings.json config.toml
Hooks ~/.claude/hooks/ 지원하지 않음
Skills ~/.claude/skills/ 지원하지 않음
지시 파일 CLAUDE.md AGENTS.md
승인 모드 --dangerously-skip-permissions suggest / auto-edit / full-auto

핵심 차이점: Codex CLI는 hooks를 지원하지 않습니다. 자동 컨텍스트 주입 패턴(PreToolUse hook)을 사용할 수 없습니다. 대신 AGENTS.md에 작업 시작 전 볼트를 검색하도록 명시적인 지시를 포함하세요.


Cursor 및 기타 도구

Cursor와 MCP를 지원하는 기타 AI 도구는 동일한 Obsidian MCP 서버에 연결할 수 있습니다. 이 섹션에서는 주요 도구의 설정 방법을 다룹니다.

Cursor

프로젝트 루트의 .cursor/mcp.json에 다음을 추가하세요:

{
  "mcpServers": {
    "obsidian": {
      "command": "python",
      "args": ["/path/to/obsidian_mcp.py"],
      "env": {
        "VAULT_PATH": "/absolute/path/to/vault",
        "DB_PATH": "/absolute/path/to/vectors.db"
      }
    }
  }
}

Cursor의 .cursorrules 파일에 볼트 사용 지시를 포함할 수 있습니다:

When working on implementation tasks, search the Obsidian vault
for relevant context before writing code. Use the obsidian_search
tool with descriptive queries about the concept you're implementing.

호환성 매트릭스

도구 MCP 지원 전송 방식 설정 위치
Claude Code 전체 지원 STDIO ~/.claude/settings.json
Codex CLI 전체 지원 STDIO .codex/config.toml
Cursor 전체 지원 STDIO .cursor/mcp.json
Windsurf 전체 지원 STDIO .windsurf/mcp.json
Continue.dev 부분 지원 HTTP ~/.continue/config.json
Zed 개발 중 STDIO 설정 UI

MCP를 지원하지 않는 도구를 위한 대안

MCP를 지원하지 않는 도구의 경우, 검색기를 CLI로 래핑할 수 있습니다:

# Search from command line
python retriever_cli.py search "query text" --limit 5

# Output formatted for copy-paste into any tool
python retriever_cli.py context "query text" --format markdown

CLI는 구조화된 텍스트를 출력하므로, 이를 복사하여 모든 AI 도구의 입력에 붙여넣을 수 있습니다. MCP 통합보다는 덜 세련되지만, 범용적으로 사용할 수 있습니다.


구조화된 노트를 활용한 프롬프트 캐싱

볼트의 구조화된 노트는 AI 상호작용 전반에서 토큰 사용량을 줄이는 재사용 가능한 컨텍스트 블록으로 활용할 수 있습니다. 이 섹션에서는 캐시 키 설계와 토큰 예산 관리를 다룹니다.

패턴

매 상호작용마다 컨텍스트를 검색하는 대신, 잘 구조화된 볼트 노트에서 컨텍스트 블록을 미리 구축하고 캐싱합니다:

# cache_keys.py
CONTEXT_BLOCKS = {
    "auth-patterns": {
        "vault_query": "authentication patterns implementation",
        "max_tokens": 1500,
        "ttl_hours": 24,  # Rebuild daily
    },
    "api-conventions": {
        "vault_query": "API design conventions REST patterns",
        "max_tokens": 1000,
        "ttl_hours": 168,  # Rebuild weekly
    },
    "project-architecture": {
        "vault_query": "current project architecture decisions",
        "max_tokens": 2000,
        "ttl_hours": 12,  # Rebuild twice daily
    },
}

캐시 무효화

캐시 무효화는 두 가지 신호를 기반으로 합니다:

  1. TTL 만료. 각 컨텍스트 블록에는 유효 시간(TTL)이 있습니다. TTL이 만료되면 볼트를 다시 쿼리하여 블록을 재구축합니다.
  2. 볼트 변경 감지. 인덱서가 캐시된 컨텍스트 블록에 기여한 파일의 변경을 감지하면 해당 블록은 즉시 무효화됩니다.

토큰 예산 관리

세션은 전체 컨텍스트 예산으로 시작됩니다. 캐시된 블록은 이 예산의 일부를 소비합니다:

Total context budget:    8,000 tokens
├─ System prompt:        1,500 tokens
├─ Cached blocks:        3,000 tokens (pre-loaded)
├─ Dynamic search:       2,000 tokens (on-demand)
└─ Conversation:         1,500 tokens (remaining)

캐시된 블록은 세션 시작 시 로드됩니다. 동적 검색 결과는 쿼리별로 남은 예산을 채웁니다. 이 하이브리드 접근 방식은 에이전트에게 자주 필요한 컨텍스트의 기본 베이스라인을 제공하면서도 특정 쿼리를 위한 예산을 확보합니다.

캐싱 전후 토큰 사용량 비교

캐싱 없이: 관련 쿼리가 발생할 때마다 볼트 검색이 트리거되어 1,500~2,000 토큰의 컨텍스트가 반환됩니다. 세션 내 10개의 쿼리에서 에이전트는 15,000~20,000 토큰의 볼트 컨텍스트를 소비합니다.

캐싱 적용 시: 미리 구축된 3개의 컨텍스트 블록이 총 4,500 토큰을 소비합니다. 추가 검색은 고유 쿼리당 1,500~2,000 토큰을 더합니다. 10개의 쿼리 중 6개가 캐시된 블록으로 처리되는 경우, 에이전트는 4,500 + (4 × 1,500) = 10,500 토큰을 소비합니다 — 캐싱하지 않았을 때의 약 절반 수준입니다.


PostToolUse Hooks를 활용한 컨텍스트 압축

도구 출력은 스택 트레이스, 파일 목록, 테스트 결과 등으로 장황해질 수 있습니다. PostToolUse hook을 사용하면 이러한 출력이 컨텍스트 윈도우를 소비하기 전에 압축할 수 있습니다.

문제점

테스트를 실행하는 Bash 도구 호출은 다음과 같은 결과를 반환할 수 있습니다:

PASSED tests/test_auth.py::test_login_success
PASSED tests/test_auth.py::test_login_failure
PASSED tests/test_auth.py::test_token_refresh
PASSED tests/test_auth.py::test_session_expiry
... (200 more lines)
FAILED tests/test_api.py::test_rate_limit_exceeded

전체 출력은 5,000 토큰이지만, 실제 유의미한 정보는 2줄에 불과합니다: 200개 통과, 1개 실패.

Hook 구현

#!/bin/bash
# ~/.claude/hooks/post-tool-use/compress-output.sh
# Compress verbose tool outputs to preserve context window

TOOL_NAME="$1"
OUTPUT="$2"
OUTPUT_LEN=${#OUTPUT}

# Only compress large outputs
if [ "$OUTPUT_LEN" -lt 2000 ]; then
    exit 0  # Pass through unchanged
fi

case "$TOOL_NAME" in
    Bash)
        # Compress test output
        if echo "$OUTPUT" | grep -q "PASSED\|FAILED"; then
            PASSED=$(echo "$OUTPUT" | grep -c "PASSED")
            FAILED=$(echo "$OUTPUT" | grep -c "FAILED")
            FAILURES=$(echo "$OUTPUT" | grep "FAILED")
            echo "Tests: $PASSED passed, $FAILED failed"
            if [ "$FAILED" -gt 0 ]; then
                echo "Failures:"
                echo "$FAILURES"
            fi
        fi
        ;;
esac

재귀적 트리거 방지

압축 hook이 출력을 생성하면 가드가 없을 경우 자기 자신을 트리거할 수 있습니다:

# Guard against recursive invocation
if [ -n "$COMPRESS_HOOK_ACTIVE" ]; then
    exit 0
fi
export COMPRESS_HOOK_ACTIVE=1

압축 휴리스틱

출력 유형 감지 방법 압축 전략
테스트 결과 PASSED / FAILED 키워드 통과/실패 건수를 세고, 실패 항목만 표시
파일 목록 명령어에 ls 또는 find 포함 처음 20개 항목으로 자르고 총 개수 표시
스택 트레이스 Traceback 키워드 첫 번째와 마지막 프레임 + 에러 메시지만 유지
Git 상태 modified: / new file: 상태별 개수 요약
빌드 출력 warning: / error: 정보 라인 제거, 경고/에러만 유지

시그널 수집 및 분류 파이프라인

수집 레이어는 볼트에 무엇이 들어올지를 결정합니다. 큐레이션 없이는 볼트에 노이즈가 쌓이게 됩니다. 이 섹션에서는 시그널을 도메인 폴더로 라우팅하는 스코어링 파이프라인을 다룹니다.

소스

시그널은 다양한 채널에서 유입됩니다:

  • RSS 피드: 기술 블로그, 보안 권고, 릴리스 노트
  • 북마크: Obsidian Web Clipper 또는 북마클릿을 통해 저장된 브라우저 북마크
  • 뉴스레터: 이메일 뉴스레터의 핵심 발췌
  • 수동 캡처: 읽기, 대화, 또는 리서치 중에 작성한 노트
  • 도구 출력: 훅을 통해 캡처된 중요한 AI 도구 출력

평가 차원

각 시그널은 네 가지 차원(각각 0.0~1.0)으로 평가됩니다:

차원 질문 낮은 점수 (0.0-0.3) 높은 점수 (0.7-1.0)
관련성 현재 활성 도메인과 관련이 있는가? 주변적이며 범위 밖 현재 작업과 직접적으로 관련됨
실행 가능성 이 정보를 활용할 수 있는가? 순수 이론, 적용 불가 적용할 수 있는 구체적 기법 또는 패턴
깊이 콘텐츠가 얼마나 실질적인가? 헤드라인, 피상적 요약 예시를 포함한 상세 분석
권위성 출처가 얼마나 신뢰할 수 있는가? 익명 블로그, 미검증 1차 출처, 동료 심사, 공인된 전문가

종합 점수 및 라우팅

composite = (relevance * 0.35) + (actionability * 0.25) +
            (depth * 0.25) + (authority * 0.15)
점수 범위 조치
0.55+ 도메인 폴더로 자동 라우팅
0.40 - 0.55 수동 검토 대기열에 추가
< 0.40 폐기 (저장하지 않음)

도메인 라우팅

0.55 이상의 점수를 받은 시그널은 키워드 매칭과 주제 분류를 기반으로 12개의 도메인 폴더 중 하나로 라우팅됩니다:

05-signals/
├── ai-tooling/        # Claude, LLMs, AI development tools
├── security/          # Vulnerabilities, auth, cryptography
├── systems/           # Architecture, distributed systems
├── programming/       # Languages, patterns, algorithms
├── web/               # Frontend, backends, APIs
├── data/              # Databases, data engineering
├── devops/            # CI/CD, containers, infrastructure
├── design/            # UI/UX, product design
├── mobile/            # iOS, Android, cross-platform
├── career/            # Industry trends, hiring, growth
├── research/          # Academic papers, whitepapers
└── other/             # Signals that don't fit a domain

운영 통계

14개월간의 운영 결과:

지표
총 처리된 시그널 7,771
자동 라우팅 (>0.55) 4,832 (62%)
검토 대기 (0.40-0.55) 1,543 (20%)
폐기 (<0.40) 1,396 (18%)
활성 도메인 폴더 12
일 평균 시그널 ~18

지식 그래프 패턴

Obsidian의 wiki-link 그래프는 노트 간의 관계를 인코딩합니다. 이 섹션에서는 링크 의미론, 컨텍스트 확장을 위한 그래프 탐색, 그리고 그래프 품질을 저하시키는 안티패턴을 다룹니다.

모든 wiki-link는 그래프에서 방향성 있는 엣지를 생성합니다. Obsidian은 정방향 링크와 backlink를 모두 추적합니다:

  • 정방향 링크: 노트 A에 [[Note B]]가 포함됨 → A가 B로 링크
  • Backlink: 노트 B에서 노트 A가 자신을 참조하고 있음을 표시

그래프는 컨텍스트에 따라 다양한 유형의 관계를 인코딩합니다:

링크 패턴 의미 예시
인라인 링크 “~와 관련됨” “See [[OAuth Token Rotation]] for details”
헤더 링크 “하위 주제를 가짐” ”## Related\n- [[Token Rotation]]\n- [[Session Management]]”
태그형 링크 “~로 분류됨” ”[[type/reference]]”
MOC 링크 “~의 일부임” 관련 노트를 나열하는 Maps of Content 노트

Maps of Content (MOC)

MOC는 관련 노트를 탐색 가능한 구조로 정리하는 인덱스 노트입니다:

---
title: "Authentication & Security MOC"
type: moc
domain: security
---

## Core Concepts
- [[OAuth 2.0 Overview]]
- [[JWT Token Anatomy]]
- [[Session Management Patterns]]

## Implementation Patterns
- [[OAuth Token Rotation]]
- [[Refresh Token Security]]
- [[PKCE Flow Implementation]]

## Failure Modes
- [[Token Expiry Handling]]
- [[Session Fixation Prevention]]
- [[CSRF Defense Strategies]]

MOC는 두 가지 방식으로 검색에 도움을 줍니다:

  1. 직접 매칭. “authentication overview”를 검색하면 MOC 자체가 매칭되어, 에이전트에게 관련 노트의 큐레이션된 목록을 제공합니다.
  2. 컨텍스트 확장. 특정 노트를 찾은 후, 리트리버가 해당 노트가 포함된 MOC가 있는지 확인하고 MOC의 구조를 결과에 포함시켜, 에이전트에게 더 넓은 주제의 맵을 제공할 수 있습니다.

컨텍스트 확장을 위한 그래프 탐색

리트리버의 향후 개선 사항: 상위 결과를 찾은 후 링크를 따라가며 컨텍스트를 확장합니다:

def expand_context(results, depth=1):
    """Follow wiki-links from top results to find related context."""
    expanded = set()
    for result in results:
        # Parse wiki-links from chunk text
        links = extract_wiki_links(result["chunk_text"])
        for link_target in links:
            # Resolve link to file path
            target_path = resolve_wiki_link(link_target)
            if target_path and target_path not in expanded:
                expanded.add(target_path)
                # Include target's most relevant chunk
                target_chunks = get_chunks_for_file(target_path)
                # ... rank and include best chunk
    return results + list(expanded_results)

이 기능은 현재 리트리버에 구현되어 있지 않지만, 그래프 구조의 자연스러운 확장을 나타냅니다.

안티패턴

고립 클러스터. 서로 링크되어 있지만 볼트의 나머지 부분과는 연결이 없는 노트 그룹입니다. Obsidian의 그래프 패널에서 분리된 섬으로 확인할 수 있습니다. 고립 클러스터는 누락된 MOC 또는 도메인 간 링크가 부족함을 나타냅니다.

태그 난립. 태그를 일관성 없이 사용하거나 지나치게 세분화된 태그를 만드는 것입니다. 5,000개의 노트에 500개의 고유 태그가 있다면 태그당 평균 10개 노트에 불과하여 — 필터링에 유용하지 않습니다. 도메인 폴더에 매핑되는 20~50개의 상위 수준 태그로 통합하세요.

링크만 있고 내용이 없는 노트. wiki-link로만 구성되어 있고 산문이 없는 노트입니다. 이런 노트는 청커가 임베딩(embedding)할 텍스트가 없기 때문에 인덱싱이 잘 되지 않습니다. 링크된 노트들이 왜 관련이 있는지 설명하는 최소 한 단락의 컨텍스트를 추가하세요.

모든 곳에 양방향 링크 사용. 모든 참조가 wiki-link일 필요는 없습니다. “OAuth”를 단순히 언급하는 것에 [[OAuth 2.0 Overview]]가 필요하지는 않습니다. wiki-link는 클릭했을 때 유용한 컨텍스트를 제공하는 의도적이고 탐색 가능한 관계에만 사용하세요.


개발자 워크플로 레시피

볼트 검색과 일상 개발 작업을 결합하는 실용적인 워크플로입니다.

아침 컨텍스트 로드

관련 컨텍스트를 로드하며 하루를 시작합니다:

Search my vault for notes about [current project] updated in the last week

리트리버가 활성 프로젝트에 대한 최근 노트를 반환하여, 어디까지 작업했는지 빠르게 떠올릴 수 있습니다. 어제의 커밋 메시지를 다시 읽는 것보다 효과적입니다.

코딩 중 리서치 캡처

기능을 구현하는 동안 에디터를 떠나지 않고 인사이트를 캡처합니다:

/capture "FastAPI dependency injection with async generators requires yield,
not return. The generator is the dependency lifecycle."
  --domain programming
  --tags fastapi,dependency-injection

캡처된 인사이트는 즉시 인덱싱되어 향후 검색에 사용할 수 있습니다. 수개월에 걸쳐 이러한 마이크로 캡처가 구현별 지식의 코퍼스를 구축합니다.

프로젝트 킥오프

새 프로젝트나 기능을 시작할 때:

  1. 볼트 검색: “[기술/패턴]에 대해 내가 아는 것은?”
  2. 상위 5개 결과에서 이전 결정사항과 주의사항 검토
  3. 해당 도메인의 MOC가 있는지 확인; 없으면 생성
  4. 실패 모드 검색: “[기술]의 문제점”

볼트 검색을 활용한 디버깅

오류나 예상치 못한 동작을 만났을 때:

Search my vault for [error message or symptom]

이전 디버깅 노트에는 근본 원인과 수정 방법이 포함되어 있는 경우가 많습니다. 이는 여러 프로젝트에 걸쳐 반복되는 문제에 특히 유용합니다 — 볼트는 여러분이 잊어버린 것을 기억합니다.

코드 리뷰 준비

PR을 리뷰하기 전:

Search my vault for patterns and conventions about [module being changed]

볼트가 리뷰 대상 코드와 관련된 이전 결정사항, 아키텍처 제약 조건, 코딩 표준을 반환합니다. 리뷰가 단순한 diff가 아닌 조직의 지식에 기반하여 이루어집니다.

성능 튜닝

이 섹션에서는 다양한 볼트 크기와 사용 패턴에 따른 최적화 전략을 다룹니다.

인덱스 크기 관리

볼트 크기 청크 수 DB 크기 전체 재인덱싱 증분 업데이트
500개 노트 ~1,500 3 MB 15초 <1초
2,000개 노트 ~6,000 12 MB 45초 2초
5,000개 노트 ~15,000 30 MB 2분 4초
15,000개 노트 ~50,000 83 MB 4분 <10초
50,000개 노트 ~150,000 250 MB 15분 30초

50,000개 이상의 노트가 있는 경우 다음을 고려하세요: - 더 빠른 임베딩(embedding)을 위해 배치 크기를 64에서 128로 늘리기 - 동시 접근을 위한 WAL 모드 사용(기본값) - 사용량이 적은 시간대에 전체 재인덱싱 실행

쿼리 최적화

WAL 모드. SQLite의 Write-Ahead Logging 모드는 인덱서가 쓰기 작업을 수행하는 동안 동시 읽기를 가능하게 합니다:

db.execute("PRAGMA journal_mode=WAL")

이는 인덱서가 증분 업데이트를 실행하는 동안 MCP 서버가 쿼리를 처리할 때 매우 중요합니다.

연결 풀링. MCP 서버는 쿼리마다 새로운 연결을 여는 대신 데이터베이스 연결을 재사용해야 합니다. WAL 모드와 함께 단일 장기 연결을 사용하면 동시 읽기를 지원할 수 있습니다.

# MCP server initialization
db = sqlite3.connect(DB_PATH, check_same_thread=False)
db.execute("PRAGMA journal_mode=WAL")
db.execute("PRAGMA mmap_size=268435456")  # 256 MB mmap

메모리 매핑 I/O. mmap_size pragma는 SQLite에 데이터베이스 파일에 대해 메모리 매핑 I/O를 사용하도록 지시합니다. 83 MB 데이터베이스의 경우 전체 파일을 메모리에 매핑하면 대부분의 디스크 읽기를 제거할 수 있습니다.

FTS5 최적화. 전체 재인덱싱 후 다음을 실행하세요:

INSERT INTO chunks_fts(chunks_fts) VALUES('optimize');

이 명령은 FTS5의 내부 b-tree 세그먼트를 병합하여 이후 검색의 쿼리 지연 시간을 줄여줍니다.

확장성 벤치마크

Apple M3 Pro, 36 GB RAM, NVMe SSD에서 측정:

작업 500개 노트 5K 노트 15K 노트 50K 노트
BM25 쿼리 2ms 5ms 12ms 25ms
벡터 쿼리 1ms 3ms 8ms 20ms
RRF 융합 <1ms <1ms 3ms 5ms
전체 검색 3ms 8ms 23ms 50ms

모든 벤치마크에는 데이터베이스 접근, 쿼리 실행, 결과 포맷팅이 포함됩니다. MCP STDIO 통신의 네트워크 지연 시간은 1-2ms가 추가됩니다.


문제 해결

인덱스 불일치

증상: 검색이 오래된 결과를 반환하거나 최근 추가된 노트를 찾지 못합니다.

원인: 노트를 추가한 후 증분 인덱서가 실행되지 않았거나, 파일의 mtime이 업데이트되지 않은 경우입니다(예: 타임스탬프가 보존된 채로 다른 기기에서 동기화된 경우).

해결: 전체 재인덱싱을 실행하세요: python index_vault.py --full

임베딩 모델 변경

증상: 임베딩(embedding) 모델을 변경한 후 벡터 검색이 의미 없는 결과를 반환합니다.

원인: 이전 모델의 벡터가 새로운 쿼리 벡터와 비교되고 있습니다. 차원 수나 벡터 공간의 의미가 호환되지 않습니다.

해결: 인덱서가 모델 해시 불일치를 감지하고 자동으로 전체 재인덱싱을 트리거해야 합니다. 그렇지 않은 경우 수동으로 데이터베이스를 삭제하고 재인덱싱하세요:

rm vectors.db
python index_vault.py --full

FTS5 유지보수

증상: 다수의 증분 업데이트 후 FTS5 쿼리가 부정확하거나 불완전한 결과를 반환합니다.

원인: 많은 소규모 업데이트 후 FTS5 내부 세그먼트가 단편화될 수 있습니다.

해결: 재구축 및 최적화를 실행하세요:

INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');
INSERT INTO chunks_fts(chunks_fts) VALUES('optimize');

MCP 타임아웃

증상: AI 도구가 MCP 서버 타임아웃을 보고합니다.

원인: 첫 번째 쿼리가 모델 로딩(지연 초기화)을 트리거하며, 이 과정에 2-5초가 소요됩니다. AI 도구의 기본 MCP 타임아웃이 이보다 짧을 수 있습니다.

해결: 서버 시작 시 모델을 사전 로딩하세요:

# In MCP server initialization
retriever = HybridRetriever(db_path, vault_path)
retriever.search("warmup", limit=1)  # Trigger model load

SQLite 파일 잠금

증상: SQLITE_BUSY 또는 SQLITE_LOCKED 오류가 발생합니다.

원인: 여러 프로세스가 동시에 데이터베이스에 쓰기 작업을 수행하고 있습니다. WAL 모드는 동시 읽기를 허용하지만 쓰기는 하나의 프로세스만 가능합니다.

해결: 인덱서만 데이터베이스에 쓰기 작업을 수행하도록 하세요. MCP 서버와 훅(hook)은 읽기만 해야 합니다. 동시 쓰기가 필요한 경우 WAL 모드를 사용하고 busy timeout을 설정하세요:

db.execute("PRAGMA busy_timeout=5000")  # Wait up to 5 seconds

sqlite-vec 로딩 실패

증상: 벡터 검색이 비활성화되고 retriever가 BM25 전용 모드로 실행됩니다.

원인: sqlite-vec 확장이 설치되지 않았거나, 라이브러리 경로에서 찾을 수 없거나, SQLite 버전과 호환되지 않습니다.

해결:

# Install via pip
pip install sqlite-vec

# Or compile from source
git clone https://github.com/asg017/sqlite-vec
cd sqlite-vec && make

확장이 정상적으로 로드되는지 확인하세요:

import sqlite3
db = sqlite3.connect(":memory:")
db.enable_load_extension(True)
db.load_extension("vec0")
print("sqlite-vec loaded successfully")

대규모 볼트 메모리 문제

증상: 대규모 볼트(50,000개 이상 노트)의 전체 재인덱싱 중 메모리 부족 오류가 발생합니다.

원인: 임베딩 배치 크기가 너무 크거나 모든 파일 내용이 동시에 메모리에 로드되고 있습니다.

해결: 배치 크기를 줄이고 파일을 증분적으로 처리하세요:

BATCH_SIZE = 32  # Reduce from 64

또한 인덱서가 모든 파일을 메모리에 로드하는 대신, 각 파일을 하나씩 처리(읽기, 청킹(chunking), 임베딩)하도록 해야 합니다.


마이그레이션 가이드

Apple Notes에서 마이그레이션

  1. macOS의 “전체 내보내기” 옵션으로 Apple Notes를 내보내거나 apple-notes-liberator와 같은 마이그레이션 도구를 사용하세요
  2. markdownify 또는 pandoc를 사용하여 HTML 내보내기를 마크다운으로 변환하세요
  3. 변환된 파일을 볼트의 00-inbox/ 폴더로 이동하세요
  4. 각 노트를 검토하고 frontmatter를 추가하세요
  5. 적절한 도메인 폴더로 노트를 이동하세요

Notion에서 마이그레이션

  1. Notion에서 내보내기: 설정 → 내보내기 → Markdown & CSV
  2. 내보낸 파일을 볼트의 00-inbox/ 폴더에 압축 해제하세요
  3. Notion 고유 마크다운 아티팩트를 수정하세요:
  4. Notion은 체크리스트에 - [ ]를 사용합니다 — 이는 표준 마크다운입니다
  5. Notion은 속성 테이블을 HTML로 포함합니다 — YAML frontmatter로 변환하세요
  6. Notion은 이미지를 상대 경로로 포함합니다 — 이미지를 첨부 파일 폴더로 복사하세요
  7. 표준 frontmatter(type, domain, tags)를 추가하세요
  8. Notion 페이지 링크를 Obsidian wiki-link로 교체하세요

Google Docs에서 마이그레이션

  1. Google Takeout을 사용하여 모든 문서를 내보내세요
  2. .docx 파일을 마크다운으로 변환하세요: pandoc -f docx -t markdown input.docx -o output.md
  3. 일괄 변환: for f in *.docx; do pandoc -f docx -t markdown "$f" -o "${f%.docx}.md"; done
  4. 볼트로 이동하고 frontmatter를 추가한 후 폴더별로 정리하세요

일반 마크다운에서 마이그레이션 (Obsidian 미사용)

이미 마크다운 파일 디렉토리가 있는 경우:

  1. 디렉토리를 Obsidian 볼트로 여세요 (Obsidian → 볼트 열기 → 폴더 열기)
  2. 디렉토리가 버전 관리되는 경우 .obsidian/.gitignore에 추가하세요
  3. frontmatter 템플릿을 생성하고 기존 파일에 적용하세요
  4. 노트를 읽고 정리하면서 [[wiki-links]]로 연결을 시작하세요
  5. 인덱서를 즉시 실행하세요 — 검색 시스템은 첫날부터 작동합니다

다른 검색 시스템에서 마이그레이션

다른 임베딩/검색 시스템에서 마이그레이션하는 경우:

  1. 벡터를 마이그레이션하지 마세요. 서로 다른 모델은 호환되지 않는 벡터 공간을 생성합니다. 새 모델로 전체 재인덱싱을 실행하세요.
  2. 인덱스가 아닌 콘텐츠를 마이그레이션하세요. 볼트 파일이 원본 데이터입니다. 인덱스는 파생된 산출물입니다.
  3. 마이그레이션 후 검증하세요. 답을 알고 있는 10-20개의 쿼리를 실행하고 결과가 기대와 일치하는지 확인하세요.

변경 이력

날짜 변경 사항
2026-03-02 모델 비교에 potion-base-32M 및 potion-retrieval-32M 추가. 양자화/차원 축소 섹션 추가. MCP 스펙 발전 참고 사항 추가.
2026-03-01 최초 릴리스

참고 문헌


  1. Cormack, G.V., Clarke, C.L.A., and Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. 순위 목록을 결합하기 위한 파라미터 불필요 방식으로 k=60을 사용하는 RRF를 소개합니다. 

  2. OpenAI Embeddings Pricing. text-embedding-3-small: 백만 토큰당 $0.02. 전체 재인덱싱 시 예상 vault 비용: 약 $0.30. 

  3. van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. 문장 트랜스포머에서 정적 임베딩(embeddings)을 생성하는 증류 접근 방식을 설명합니다. 

  4. MTEB: Massive Text Embedding Benchmark. potion-base-8M은 평균 50.03점을 기록하며, all-MiniLM-L6-v2의 56.09점 대비 89%의 성능을 유지합니다. 

  5. SQLite FTS5 Extension. FTS5는 BM25 순위 지정 및 구성 가능한 컬럼 가중치를 갖춘 전문 검색 기능을 제공합니다. 

  6. sqlite-vec: A vector search SQLite extension. SQLite 내에서 KNN 벡터 검색을 위한 vec0 가상 테이블을 제공합니다. 

  7. Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. 

  8. Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. 밀집 표현이 개방형 도메인 QA에서 BM25를 9-19% 능가합니다. 

  9. Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. 밀집 의미 유사도에 관한 기초 연구입니다. 

  10. Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. 하이브리드(hybrid) 검색이 MS MARCO에서 단일 방식 접근법을 일관되게 능가합니다. 

  11. SQLite Write-Ahead Logging. 단일 쓰기 작업과 동시 읽기를 지원하는 WAL 모드입니다. 

  12. Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. RAG 아키텍처 및 청킹(chunking) 전략에 대한 서베이입니다. 

  13. Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. 

  14. Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. 

  15. Obsidian Documentation. Obsidian 공식 문서입니다. 

  16. Model Context Protocol Specification. AI 도구를 데이터 소스에 연결하기 위한 MCP 표준입니다. 

  17. 저자의 프로덕션 데이터. 16,894개 파일, 49,746개 청크, 83.56 MB SQLite 데이터베이스, 14개월간 7,771개 시그널 처리. 쿼리 지연 시간은 time.perf_counter()로 측정되었습니다. 

  18. Model2Vec Potion Models. Minish Lab, 2025. Potion-base-32M (MTEB 52.46), potion-retrieval-32M (MTEB 검색 36.35), 그리고 v0.5.0+ 양자화/차원 축소 기능을 제공합니다. 

VAULT obsidian.md INDEXED