16,894개 Obsidian 파일을 위한 하이브리드 검색기 구축
16,894개의 마크다운 파일에 grep을 실행하면 검색어에 따라 11~66초가 소요되며, 관련성이 낮은 수백 개의 결과가 반환됩니다. 벡터 검색은 의미적으로 관련된 콘텐츠를 반환하지만 정확한 함수 이름은 놓칩니다. 두 방법을 융합한 하이브리드 검색기는 API 호출 없이 단일 83 MB SQLite 파일에서 23밀리초 만에(쿼리 임베딩 포함 종단 간) 정확한 답을 반환합니다.1
집요한 노트 작성자의 문제는 수집이 아닙니다. 문제는 검색입니다. Obsidian은 기록을 마찰 없이 만들어 줍니다. 파일이 충분히 쌓이면 볼트는 쓰기 전용 데이터베이스가 됩니다: 추가하기는 쉽지만, 쿼리하기는 불가능합니다. 파일명으로 검색하는 것은 파일명이 무의미해질 때까지만 작동합니다. 전문 검색은 같은 키워드가 400개 문서에 나타날 때까지만 작동합니다. 태그는 태그 달기를 잊을 때까지만 작동합니다.
한 HN 댓글 작성자가 제가 Obsidian 볼트를 위해 구축한 검색 시스템의 전체 아키텍처를 요청했습니다.2 여기에 그 내용을 공개합니다: 청킹 전략, 임베딩 모델, 이중 인덱스 SQLite 스키마, 실제 수치가 포함된 퓨전 수학, 그리고 시스템을 수백 번 쿼리한 후 발견한 실패 모드까지.
요약
이 검색기는 FTS5 BM25 키워드 검색과 Model2Vec 벡터 유사도 검색을 결합하고, Reciprocal Rank Fusion(RRF)을 통해 단일 순위 목록으로 융합합니다. 모든 것이 하나의 SQLite 데이터베이스에서 로컬로 실행됩니다: 16,894개 파일에서 추출한 49,746개 청크가 83 MB에 담겨 있습니다. 전체 재인덱싱은 4분이 걸립니다. 증분 업데이트는 10초 이내에 완료됩니다. 이 시스템은 훅을 통해 Claude Code과 통합되어, 에이전트가 파일을 컨텍스트에 로드하지 않고도 볼트의 지식에 접근할 수 있습니다. BM25는 정확한 식별자와 함수 이름을 포착합니다. 벡터 검색은 다른 용어를 사용한 의미적 매칭을 포착합니다. RRF는 점수 보정 없이 두 결과를 병합합니다. 솔직한 트레이드오프: 태그가 잘 달린 얕은 콘텐츠가 구조화가 부족한 깊은 콘텐츠보다 높은 순위를 차지할 수 있습니다. BM25가 깊이가 아닌 키워드 밀도에 보상하기 때문입니다.
핵심 요점
대규모 볼트를 가진 노트 작성자를 위해. 제 경험상, 전문 검색만으로는 파일이 수천 개를 넘어가면 사용할 수 없게 되었습니다 — 기존 Obsidian 검색 플러그인(Smart Connections, Omnisearch)은 앱 내부에서 인덱싱하며, 다른 도구가 쿼리할 수 있는 외부 라이브러리가 아닙니다.1 BM25 위에 벡터 검색을 추가하면 개념은 기억하지만 키워드를 기억하지 못하는 쿼리를 포착할 수 있습니다. 검색기는 외부 서비스 없이, GPU 없이, API 비용 없이 SQLite에서 완전히 실행됩니다. Model2Vec는 30 MB의 정적 단어 벡터로 CPU 속도로 임베딩합니다. 트랜스포머가 아닙니다.3
검색 시스템을 구축하는 개발자를 위해. RRF는 튜닝이 가장 적게 필요한 퓨전 방법입니다. 공식은 원시 점수가 아닌 순위 위치만 사용하므로, BM25 점수를 코사인 거리에 맞춰 보정할 필요가 없습니다. k=60과 동일한 가중치로 시작하세요. 자신의 데이터에서 실패 사례를 측정한 후에만 튜닝하세요. sqlite-vec 확장은 별도의 벡터 데이터베이스 없이 SQLite 내에서 벡터 KNN 검색을 가능하게 합니다.4
Claude Code 사용자를 위해. 검색기는 훅이 호출할 수 있는 라이브러리로 실행됩니다. PreToolUse 훅이 에이전트가 작업을 시작하기 전에 볼트를 쿼리합니다. 에이전트는 전체 파일을 로드하는 대신 파일 경로 귀속이 포함된 2~3 KB의 집중된 결과를 봅니다. 이 통합은 에이전트에게 16,894개 파일의 지식에 대한 접근 권한을 제공하면서도 컨텍스트 윈도우를 작게 유지합니다.
최소 구현 버전. 가장 간단한 시작점: 마크다운 파일 위에 FTS5 가상 테이블을 생성하세요(BM25 전용, 임베딩 없이). 키워드 검색이 의미적 매칭을 놓치기 시작하면 sqlite-vec와 Model2Vec를 추가하세요. RRF 퓨전은 마지막에 추가하세요. 각 레이어는 독립적으로 작동합니다. 전체 스택에는 Python 3, 30 MB 모델 다운로드 한 번, 그리고 pip install model2vec sqlite-vec만 필요합니다. GPU 없음, Docker 없음, 외부 서비스 없음. 16,894개 파일의 총 디스크 사용량: 83 MB.
전체 운영 가이드가 필요하신가요? Obsidian AI 인프라 레퍼런스에서 볼트 아키텍처, 플러그인 설정, MCP 서버 구성, 증분 인덱싱 레시피, 문제 해결을 다룹니다 — 이 글의 아키텍처 심층 분석에 대한 단계별 동반 가이드입니다.
키워드 검색만으로는 대규모에서 실패하는 이유
전문 검색은 볼트 규모에서 예측 가능한 방식으로 무너집니다. BM25 랭킹이 포함된 FTS5는 정확한 매칭에 뛰어납니다: requestAnimationFrame을 검색하면 해당 토큰을 포함하는 모든 파일이 용어 빈도와 문서 길이에 따라 순위가 매겨져 나타납니다.5 Robertson과 Zaragoza의 확률적 관련성 모델 서베이에서도 BM25의 강점을 확인합니다: 이 알고리즘은 최소한의 파라미터 튜닝으로 키워드 중심 쿼리에서 잘 수행됩니다.14 실패 모드는 동의어와 개념 매칭입니다. “인증 실패를 처리하는 방법”을 검색하면 BM25는 “인증” 또는 “실패”를 개별적으로 언급하는 모든 파일을 반환하여, 결과가 간접적으로만 관련된 콘텐츠로 희석됩니다.
벡터 검색은 동의어 문제를 해결합니다. 쿼리를 임베딩하고 임베딩이 벡터 공간에서 가까이 위치하는 청크를 찾습니다. “인증 실패를 처리하는 방법”은 “로그인 오류 복구” 및 “세션 만료 처리”에 대한 콘텐츠와 매칭됩니다. 임베딩이 다른 용어에 걸친 의미적 유사성을 포착하기 때문입니다.6 Karpukhin 등은 Dense Passage Retrieval(DPR)을 통해 밀집 임베딩이 오픈 도메인 질의응답에서 BM25를 top-20 정확도 기준 9~19% 능가한다는 것을 입증했습니다. 밀집 표현이 어휘적 겹침을 넘어 의미를 포착하기 때문입니다.15 실패 모드는 정반대입니다: 벡터 검색은 정확한 식별자를 놓칩니다. 함수 이름 _rrf_fuse를 검색하면 벡터 검색은 퓨전과 랭킹 알고리즘에 대한 콘텐츠를 반환하지만, 실제 함수 정의가 개념적 설명보다 낮은 순위에 위치할 수 있습니다.
어느 방법도 단독으로는 두 가지 실패 모드를 모두 커버하지 못합니다. 단일 쿼리로 차이를 보여드리겠습니다(우월성의 증명이 아니라 — 종합적 평가에는 골든 셋이 필요하며, 이 시스템에는 아직 없습니다). 쿼리 “PostToolUse hook for context compression”은 각 방법에서 서로 다른 상위 3개 결과를 반환합니다:
| 순위 | BM25 단독 | 벡터 단독 | 하이브리드 (RRF) |
|---|---|---|---|
| 1 | hook-stdlib.sh “PostToolUse Handler” | context-is-the-new-memory.md “Compression Layers” | context-is-the-new-memory.md “Compression Layers” |
| 2 | settings.json “PostToolUse Events” | token-budget-analysis.md “Context Engineering” | hook-stdlib.sh “PostToolUse Handler” |
| 3 | compress-output.sh “Tool Output Filter” | agent-memory-patterns.md “Retrieval Integration” | compress-output.sh “Tool Output Filter” |
BM25는 정확한 훅 파일과 설정 레퍼런스를 찾았지만(“PostToolUse” 키워드 매칭) 개념적인 컨텍스트 엔지니어링 노트를 놓쳤습니다. 벡터 검색은 압축 전략 노트를 찾았지만(“context compression” 의미적 매칭) 특정 훅 구현을 놓쳤습니다. RRF는 개념과 구현 모두에 중요한 노트를 승격시켜, 전략 노트와 훅 파일을 1위와 2위에 배치했습니다.13
MS MARCO 패시지 랭킹에 대한 연구도 웹 검색 벤치마크에서 이 패턴을 뒷받침합니다: 하이브리드 검색은 BM25 또는 밀집 검색 단독보다 일관되게 우수하며, 특정 용어와 추상적 개념이 모두 포함된 쿼리에서 가장 큰 향상을 보입니다.716
아키텍처: 복합 효과를 내는 세 개의 레이어
시스템은 세 개의 독립적인 레이어로 구성됩니다. 각각은 다른 레이어 없이도 작동하지만, 함께 사용하면 복합 효과를 냅니다.
레이어 1: 수집. 733줄의 Python 스코어링 파이프라인이 모든 수신 시그널을 네 가지 차원에서 평가합니다: 관련성, 실행 가능성, 깊이, 권위. 0.55 이상의 점수를 받은 시그널은 12개 도메인 폴더 중 하나로 자동 라우팅됩니다. 0.40에서 0.55 사이의 시그널은 수동 검토 대기열에 들어갑니다. 0.40 미만이면 파이프라인이 시그널을 드롭합니다. 파이프라인은 14개월에 걸쳐 수동 태깅 없이 7,771개의 시그널을 처리했습니다.1 수집 레이어는 볼트에 무엇이 들어오는지를 결정합니다. 검색 레이어는 그것을 찾을 수 있게 만듭니다.
레이어 2: 검색. 아래에서 상세히 다루는 하이브리드 검색 엔진입니다. 엔진은 모든 파일을 제목 경계에서 청크로 분할하고, Model2Vec로 청크를 임베딩하며, 벡터 KNN을 위한 vec0 테이블과 BM25를 위한 FTS5 가상 테이블 모두를 사용하여 SQLite에 인덱싱합니다. 쿼리는 두 인덱스에 동시에 실행되며, RRF가 결과를 단일 순위 목록으로 융합합니다.
레이어 3: 통합. 검색기를 에이전트의 워크플로우에 연결하는 Claude Code 훅입니다. 훅은 프롬프트 제출 시 실행되어, 볼트에서 관련 컨텍스트를 쿼리하고, 상위 결과를 대화에 주입합니다. 에이전트는 원본 파일 내용 대신 출처 귀속이 포함된 집중된 청크를 봅니다:
# Illustrative output (format matches production, content simplified)
## Relevant Memory Context
### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...
### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...
각 결과에는 섹션 제목과 소스 프로젝트가 포함되며, 컨텍스트 비대화를 방지하기 위해 500 토큰 예산으로 제한됩니다.
검색기는 두 번째 통합 지점도 가능하게 합니다: 도구 출력이 대화에 들어가기 전에 압축하는 PostToolUse 훅입니다. 원시 도구 출력에는 타임스탬프, 순서 아티팩트, 실행마다 달라지는 장황한 포맷팅이 포함됩니다. 검색기는 원시 덤프를 안정적이고 집중된 부분 집합으로 대체합니다. 에이전트는 노이즈를 보지 않고, 관련 발췌만 봅니다. 부가적 이점: 검색기의 출력은 동일한 쿼리에 대해 결정론적이므로(같은 인덱스 상태는 같은 순위 결과를 생성), 압축된 출력은 프롬프트 캐싱에 도움이 됩니다. 변경되지 않은 데이터에 대한 반복 쿼리는 동일한 컨텍스트 블록을 생성하고, CLI의 자동 프롬프트 캐싱이 캐시된 접두사를 재사용합니다.
더 넓은 인프라 스토리에서 훅, 스킬, 에이전트가 모델 주변의 프로그래밍 가능한 레이어로 어떻게 구성되는지 설명합니다.
레이어들은 설계상 분리되어 있습니다. 수집 스코어링은 임베딩에 대해 아무것도 모릅니다. 검색기는 시그널 라우팅 규칙에 대해 아무것도 모릅니다. 그러나 수집은 볼트에 고품질 콘텐츠가 포함되도록 보장하고, 검색은 모든 쿼리에 대해 적절한 부분 집합을 표면화하며, 통합은 컨텍스트 비대화 없이 해당 부분 집합을 에이전트에 전달합니다. 컨텍스트를 핵심 자원으로 보는 이론적 프레이밍에 대해 글을 쓴 바 있습니다. 검색기는 그 실용적 구현입니다.
청킹: 검색 품질이 시작되는 곳
청킹은 검색 결과의 세분화 수준을 결정합니다. 청크가 너무 크면 벡터 검색이 한 단락만 관련 있는 전체 파일을 반환합니다. 청크가 너무 작으면 임베딩이 의미적 매칭에 필요한 컨텍스트를 잃습니다. RAG 파이프라인에 대한 연구에서도 대부분의 사용 사례에서 청크 크기가 모델 선택보다 검색 품질에 더 큰 영향을 미치며, 200~500 토큰 청크가 단락 수준 검색 작업에서 가장 좋은 성능을 보인다고 확인합니다.18
청커는 마크다운 구조를 유지하면서 H2(##) 제목 경계에서 분할합니다.8 OAuth 토큰 로테이션에 대한 노트가 세 개의 H2 섹션을 가지고 있으면 세 개의 청크가 되며, 각각은 임베딩이 의미를 포착할 수 있을 만큼 자기 완결적입니다. 인덱서는 제목 텍스트와 상위 노트 제목을 각 청크와 함께 메타데이터로 저장하여, 청크 텍스트 자체가 희소한 경우에도 BM25 매칭을 위한 컨텍스트를 제공합니다.
# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000
def _split_at_headings(body):
sections = []
current_heading = ""
current_lines = []
for line in body.split("\n"):
if line.startswith("## "):
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
current_heading = line[3:].strip()
current_lines = []
else:
current_lines.append(line)
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
return sections
청커는 2,000자를 초과하는 섹션을 추가로 분할합니다: 먼저 H3 경계에서, 그다음 단락 구분에서. 30자 미만의 섹션은 드롭합니다. 청커는 또한 Related, See Also, Links, References 섹션을 건너뛰는데, 이들은 일반적으로 검색 가능한 콘텐츠가 아닌 위키링크 목록이기 때문입니다.
검색 품질에 영향을 미치는 두 가지 설계 선택이 있습니다. 첫째, 인덱서는 제목 컨텍스트 문자열("OAuth Token Rotation | note | security, authentication")을 별도의 컬럼에 저장하고 청크 텍스트(1.0)보다 낮은 가중치(0.3)로 FTS5에 인덱싱합니다. 청크 본문에 검색어가 포함되지 않은 경우에도 BM25가 제목에서 매칭되지만, 제목 매칭은 본문 매칭보다 낮은 점수를 받습니다. 둘째, 청커는 프론트매터 태그와 노트 유형을 추출하여 제목 컨텍스트에 포함시키므로, “security”를 검색하면 본문 텍스트가 다른 용어를 사용하더라도 security로 태그된 노트가 매칭됩니다.
임베딩: 30 MB 모델, API 호출 제로
임베딩 모델은 Model2Vec의 potion-base-8M으로, 760만 개의 파라미터를 가지고 256차원 벡터를 생성하는 정적 단어 임베딩 모델입니다.3 MTEB 벤치마크 스위트에서 potion-base-8M은 all-MiniLM-L6-v2 성능의 89%를 달성하면서(평균 50.03 대 56.09) 최대 500배의 추론 속도를 보여, 소비자 하드웨어에서 대규모 말뭉치를 인덱싱하는 데 실용적입니다.917 한 가지 주의점: 모델의 MTEB 검색 하위 점수(31.71)는 분류(64.44)나 STS(73.24) 점수에 비해 눈에 띄게 낮습니다. MTEB의 검색 벤치마크는 웹 말뭉치에서의 문서 수준 랭킹을 테스트하며, 동질적인 마크다운 청크에서의 단락 수준 매칭이 아닙니다. 청크가 짧고, 주제적으로 집중되어 있으며, 일관된 어휘로 작성된 경우 이 차이는 덜 중요합니다. 트랜스포머 기반 임베딩 모델과 달리, Model2Vec는 입력에 대해 어텐션 레이어를 실행하지 않습니다. 모델은 문장 트랜스포머의 지식을 정적 토큰 임베딩으로 증류하여, 순차 연산이 아닌 가중 평균을 통해 벡터를 생성합니다.9
정적 임베딩이 이 사용 사례에서 작동하는 이유는 무엇일까요? 짧은 마크다운 청크(평균 200~400단어)는 단일 주제에 대한 집중된 어휘를 포함합니다. 해당 토큰 벡터의 가중 평균은 주제 외 희석이 거의 없기 때문에 임베딩 공간의 의미 있는 영역에 위치합니다. 실제로, 세 가지 다른 주제를 다루는 2,000단어 문서는 하나의 클러스터 내가 아닌 주제 클러스터 사이에 위치하는 흐릿한 중심점을 생성하는 경향이 있습니다. 반면, OAuth 토큰 로테이션에 대한 청크는 다른 인증 콘텐츠와 밀접하게 클러스터링되는 벡터를 생성합니다. 정적 임베딩은 문맥적 중의성 해소(disambiguation)를 원시 속도와 교환합니다(“bank”가 “강둑”인지 “은행”인지). 각 청크가 하나의 개념을 다루는 개인 지식 베이스에서는 모호성 패널티가 작으며, 논문에서는 최대 500배의 추론 속도 향상을 보고합니다.9
# embedder.py: lazy-loading Model2Vec in a dedicated venv
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 memory 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]
실용적 결과: Apple M3 Pro에서 16,894개 파일의 전체 재인덱싱이 4분 만에 완료됩니다. 증분 인덱싱(mtime 비교로 감지된 변경된 파일만)은 일반적인 하루 편집량에 대해 10초 이내에 실행됩니다.1
모델은 나머지 도구 체인과의 종속성 충돌을 피하기 위해 ~/.claude/venvs/memory/의 격리된 가상 환경에서 실행됩니다. 임베더는 임포트 시가 아닌 최초 사용 시 모델을 지연 로드하므로, 검색기가 BM25 전용 모드로 폴백할 때 모듈 임포트 비용이 없습니다.
더 큰 모델을 사용하지 않는 이유는? 두 가지입니다. 첫째, 256차원 벡터는 49,746개 청크에 대해 SQLite 데이터베이스를 83 MB로 유지합니다. 더 높은 차원의 벡터(768 또는 1,024)는 짧은 마크다운 청크에 대한 미미한 품질 향상을 위해 데이터베이스 크기를 3~4배로 늘립니다.10 둘째, API 기반 임베딩(예: OpenAI의 text-embedding-3-small, 백만 토큰당 $0.02)은 오프라인에서 작동해야 하는 시스템에 지연 시간, 비용, 네트워크 종속성을 도입합니다.11 전체 볼트 재임베딩은 API 가격으로 약 $0.30이며 이것만 보면 사소하지만, 실제 비용은 49,746개 청크에 곱해지는 왕복 지연 시간과 개인 노트를 외부 API에 전송하는 프라이버시 문제입니다.
모델 해시 메커니즘이 임베딩 호환성을 추적합니다. 인덱서는 모델 이름과 어휘 크기에서 파생된 해시를 저장합니다. 모델이 변경되면 증분 인덱싱이 불일치를 감지하고 자동으로 전체 재인덱싱을 트리거합니다.
SQLite 스키마: 세 개의 테이블, 하나의 파일
전체 인덱스는 동시 읽기 안전을 위한 WAL 모드를 사용하는 하나의 SQLite 파일(vectors.db, 83 MB)에 있습니다.12 세 개의 테이블이 각각 다른 용도를 수행합니다:
-- 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
);
-- FTS5 for BM25 search (content-synced to chunks)
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]
);
FTS5 테이블은 content-sync 패턴을 사용합니다: 텍스트의 중복 복사본을 저장하는 대신 chunks 테이블을 직접 참조합니다.5 한 가지 주의점: content-sync 테이블은 삭제를 자동으로 전파하지 않습니다. 인덱서는 chunks 테이블에서 행을 제거하기 전에 명시적으로 INSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?) 명령을 실행해야 하며, 그렇지 않으면 FTS5 인덱스가 조용히 불일치 상태가 됩니다. BM25 쿼리의 컬럼 가중치는 청크 텍스트에 1.0, 섹션 제목에 0.5, 제목 컨텍스트에 0.3을 할당합니다:
# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score
sqlite-vec 확장은 256차원 float 벡터를 압축된 바이너리 데이터로 저장하고 코사인 거리를 사용한 KNN 쿼리를 지원합니다.4 Python의 struct.pack이 벡터를 직렬화합니다:
def _serialize_vector(vec):
return struct.pack(f"{len(vec)}f", *vec)
스키마는 설계상 우아한 성능 저하를 처리합니다. sqlite-vec 로드에 실패하면(확장 누락, 호환되지 않는 플랫폼), 검색기는 BM25 전용 검색으로 폴백합니다. vec_available 속성이 벡터 검색의 작동 여부를 추적합니다.
Reciprocal Rank Fusion: 작동하게 만드는 수학
RRF는 점수 보정 없이 두 개의 순위 목록을 병합합니다.7 원시 점수를 직접 결합하지 않는 이유는? BM25는 음의 관련성 점수를 반환하며(SQLite FTS5 구현에서 더 음수 = 더 관련 있음) 코사인 거리는 0에서 2 사이의 값을 반환합니다. 이 스케일을 비교하려면 쿼리 분포에 민감한 정규화가 필요합니다. RRF는 점수가 아닌 순위 위치만 사용하여 이 문제를 완전히 우회합니다. 공식은 각 문서에 각 목록에서의 위치에 기반한 점수를 부여합니다:
score(d) = Σ (weight_i / (k + rank_i))
여기서 k는 상수(구현에서 60, 원본 Cormack 등의 논문을 따름7), rank_i는 결과 목록 i에서의 문서 순위, weight_i는 선택적 목록별 가중치(두 목록 모두 기본값 1.0)입니다.
실제 순위를 사용한 풀이 예시입니다. 쿼리를 살펴보겠습니다: “how does the review aggregator handle disagreements.” 결합된 결과에서 다섯 개의 청크가 나타납니다:
| 청크 | BM25 순위 | Vec 순위 | BM25 RRF | Vec RRF | 융합 점수 |
|---|---|---|---|---|---|
| review-aggregator.py “Disagreement Resolution” | 3 | 1 | 1/63 = 0.0159 | 1/61 = 0.0164 | 0.0323 |
| deliberation-config.json “Review Weights” | 1 | 8 | 1/61 = 0.0164 | 1/68 = 0.0147 | 0.0311 |
| code-review MOC “Multi-Agent Review” | 7 | 2 | 1/67 = 0.0149 | 1/62 = 0.0161 | 0.0310 |
| jiro-artisan.sh “Review State Machine” | 2 | 12 | 1/62 = 0.0161 | 1/72 = 0.0139 | 0.0300 |
| quality-loop.md “Evidence Gate” | - | 3 | 0 | 1/63 = 0.0159 | 0.0159 |
첫 번째 청크가 승리합니다. 두 목록 모두에서 좋은 순위를 차지했기 때문입니다. BM25는 텍스트에서 “review”, “aggregator”, “disagreements”를 매칭했습니다. 벡터 검색은 코드 리뷰에서의 갈등 해결이라는 의미적 개념을 매칭했습니다. 두 번째 청크는 BM25에서 1위(설정 파일의 “review” 정확한 키워드 매칭)였지만 벡터 검색에서 8위(설정 JSON은 의미적으로 희소)였습니다. RRF가 적절하게 순위를 낮추었습니다. 마지막 청크는 벡터 결과에만 나타났으므로, 하나의 소스에서만 RRF 점수를 받았습니다.
# retriever.py: RRF fusion core
RRF_K = 60
def _rrf_fuse(self, bm25_results, vec_results,
bm25_weight=1.0, vec_weight=1.0):
scores = {}
for rank, r in enumerate(bm25_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
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, ...}
scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
scores[cid]["vec_rank"] = rank
return [SearchResult(chunk_id=cid, **data)
for cid, data in scores.items()]
기본 후보 풀은 퓨전 전 각 소스에서 30개의 결과이며, 최대 60개의 후보를 생성합니다. 검색기는 상위 10개의 융합된 결과를 반환합니다. 선택적 max_tokens 파라미터는 토큰 예산에 맞추어 결과를 잘라내며, 문자 4개당 1토큰으로 추정합니다.
인덱싱: 전체 및 증분
인덱서는 두 가지 모드를 지원합니다. 전체 재인덱싱은 데이터베이스를 지우고 처음부터 재구축합니다. 증분 인덱싱은 파일 수정 시간(mtime_ns)을 저장된 타임스탬프와 비교하여 변경된 파일만 재처리합니다.1
# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes) # mtime changed or new
deleted = index.get_deleted_files(vault_paths) # no longer in vault
임베딩은 Model2Vec의 오버헤드를 분산시키기 위해 64개 텍스트 배치로 실행됩니다.8 전체 재인덱싱 중 500개 파일마다 진행 카운터가 출력됩니다. SIGINT 핸들러가 현재 파일 처리를 마친 후 중지하는 정상 종료를 가능하게 합니다.
설정 파일은 폴더 인덱싱을 제어하기 위해 허용 목록 모델을 사용합니다. 볼트에는 22개의 허용된 폴더와 5개의 영구 제외 폴더(개인 건강 노트, 경력 문서, Obsidian 내부 디렉토리)가 있습니다.20 인덱서는 허용된 폴더 내의 파일만 처리하고 나머지는 모두 건너뜁니다.
중요한 설계 선택 하나: 인덱서는 저장 전 모든 청크에 크리덴셜 필터를 실행합니다. 개인 노트에는 디버깅 세션 중에 붙여넣은 API 키, 베어러 토큰, 데이터베이스 연결 문자열, 개인 키가 포함됩니다. 크리덴셜 필터는 21개의 벤더별 패턴(OpenAI 키, GitHub PAT, AWS 액세스 키, Stripe 토큰 및 17개 기타) 및 데이터베이스 URL, JWT, 베어러 토큰, 비밀번호 할당, 고엔트로피 base64 문자열에 대한 11개의 일반 감지기를 매칭합니다.20 필터는 매칭된 콘텐츠를 [REDACTED:pattern-name] 토큰으로 대체하고, 어떤 패턴이 트리거되었는지 로깅하되 시크릿 자체는 절대 로깅하지 않습니다.
# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
logger.info("Scrubbed %d credential(s) from %s [%s]",
scan_result.match_count, file_path, sub_heading)
크리덴셜 필터링 없이 개인 노트를 인덱싱하면 시크릿의 검색 가능한 데이터베이스가 만들어집니다. 필터는 임베딩 전에 실행되므로, 벡터 표현은 크리덴셜 패턴을 절대 인코딩하지 않습니다. “API key”를 쿼리하면 실제 키를 포함하는 노트가 아니라 API 키 관리를 논의하는 노트가 반환됩니다.
무엇이 잘못되는가: 솔직한 실패 모드
프로덕션 인덱스에 대해 수백 건의 쿼리를 실행한 후, 네 가지 실패 패턴이 명확합니다.
키워드가 밀집된 얕은 콘텐츠가 깊은 콘텐츠보다 높은 순위를 차지합니다. security, authentication, oauth로 태그된 세 문장짜리 요약이 있는 짧은 노트가, 서론에서 용어를 한 번 언급하고 나서 구체적인 프로토콜 세부사항으로 전환하는 2,000단어 분량의 OAuth 구현 심층 분석보다 BM25에서 높은 점수를 받습니다. BM25는 문서 길이 대비 용어 빈도에 보상하며, Robertson과 Zaragoza가 알고리즘의 “용어 빈도 포화” 구성요소로 문서화한 속성입니다.514 얕은 노트가 더 높은 키워드 밀도를 가집니다. 벡터 검색이 깊은 콘텐츠를 더 높게 랭킹하기 때문에(임베딩이 의미적 깊이를 포착) RRF가 이 문제를 부분적으로 보정하지만, 얕은 노트는 아마도 나타나지 않아야 할 때에도 융합된 결과에 여전히 나타납니다.
구조화된 데이터는 인덱싱이 잘 되지 않습니다. JSON 설정 파일, YAML 프론트매터 블록, 변수 이름이 있는 코드 스니펫은 낮은 품질의 BM25 매칭을 생성합니다. “review configuration”을 검색하면 review 키가 있는 모든 JSON 파일이 매칭됩니다. 벡터 검색은 임베딩이 키-값 관계를 포착하기 때문에 구조화된 데이터를 약간 더 잘 처리하지만, 구조화된 콘텐츠는 본질적으로 산문보다 청킹하기 어렵습니다. 임베딩 전에 JSON을 key-path: value 쌍으로 평탄화하면 설정이 많은 노트의 검색 품질이 향상될 것입니다.
청크 경계가 컨텍스트를 분할합니다. 청커는 두 H2 섹션의 경계에 걸쳐 있는 단락을 두 개의 청크로 분할합니다. 각 청크에는 설명의 절반만 포함됩니다. 임베딩이 전체 컨텍스트가 부족하기 때문에 어느 청크도 잘 임베딩되지 않습니다. 청커는 제목 컨텍스트(상위 제목을 메타데이터에 전달)로 이 문제를 완화하지만, 본문 텍스트는 여전히 경계에서 연속성을 잃습니다. 오버래핑 윈도우가 도움이 되겠지만 청크 수와 데이터베이스 크기를 증가시킵니다.
시간적 관련성이 보이지 않습니다. 검색기에는 최신성의 개념이 없습니다. 14개월 전의 초기 아키텍처 결정에 대한 노트가 어제의 현재 구현에 대한 노트와 동일한 순위를 받습니다. 진화하는 지식 베이스에서는 새로운 노트가 종종 이전 노트를 대체합니다. 검색기는 이를 알지 못합니다.
다음 단계: 확장 로드맵
다섯 가지 추가 사항이 실패 모드를 해결하고 시스템의 기능을 확장할 것입니다.
Learning-to-rank 재순위 레이어. RRF 퓨전 후, 경량 재순위기가 메타데이터 시그널을 기반으로 점수를 조정할 수 있습니다: 노트 최신성, 쿼리 도메인에 대한 태그 관련성, 링크 밀도(많이 링크된 노트는 종종 더 권위 있음). 재순위기는 전체 말뭉치가 아닌 융합된 상위 30개 결과에 대해 실행되어, 23ms 기준선 이내의 지연 시간을 유지합니다.
쿼리 의도 분류. 서로 다른 쿼리에는 서로 다른 검색 전략이 필요합니다. 정확한 식별자 조회(_rrf_fuse)는 BM25에 높은 가중치를 둬야 합니다. 개념적 질문(“how does review handle disagreements”)은 벡터 검색에 가중치를 둬야 합니다. 쿼리별로 bm25_weight와 vec_weight를 조정하는 경량 분류기가 퓨전 아키텍처를 변경하지 않으면서 정밀도를 향상시킬 것입니다.
시간적 감쇠. 현재 상태에 대한 쿼리에서 최근 노트에 약간 더 높은 가중치를 부여합니다. 퓨전 후 적용되는 감쇠 함수가 N개월 전에 마지막으로 수정된 파일의 청크 점수를 낮출 것입니다. mtime_ns 타임스탬프가 이미 스키마에 존재하므로, 감쇠에는 검색기의 가중치 함수만 필요합니다.
골든 쿼리를 포함한 평가 하니스. 현재 시스템에는 자동화된 품질 측정이 없습니다. 50~100개의 큐레이팅된 쿼리-답변 쌍 세트가 검색 품질 회귀 테스트를 가능하게 할 것입니다: 청킹, 임베딩 또는 퓨전 파라미터 변경 후 테스트 스위트를 실행하고 recall@10이 저하되지 않는지 확인합니다. BEIR 벤치마크에서 검색 시스템이 다양한 쿼리 분포에 걸쳐 nDCG@10에서 20점 이상 차이가 날 수 있음이 입증되었으며, 이는 도메인별 평가가 필수적임을 보여줍니다.19 골든 셋이 없으면 개선은 일화적일 뿐입니다.
교차 노트 관계 인덱싱. Obsidian 위키링크([[note-name]])는 노트 간의 명시적 관계를 인코딩합니다. 현재 시스템은 링크 구조를 완전히 무시합니다. 링크 대상을 메타데이터로 인덱싱하면 검색기가 다른 높은 점수의 노트에서 많이 링크하는 노트의 청크를 부스트할 수 있습니다. 볼트를 위한 PageRank와 유사합니다.
전체 볼트에 대해 실행한 임베딩 공간 토폴로지 분석에서 이러한 개선이 가장 큰 영향을 미칠 곳을 확인할 수 있습니다. 밀집 클러스터(AI 도구, 보안)는 용어가 일관되기 때문에 이미 검색이 잘 됩니다. 클러스터 간의 희소한 브릿지 영역이 검색기가 가장 어려움을 겪는 곳이며, 관계 인덱싱과 의도 분류가 가장 큰 향상을 제공할 곳입니다.
FAQ
전용 벡터 데이터베이스 대신 SQLite를 사용하는 이유는?
전체 검색 스택이 외부 종속성 없이 하나의 파일에서 실행됩니다. SQLite의 WAL 모드가 여러 Claude Code 세션에서의 동시 읽기를 처리합니다. sqlite-vec 확장이 별도의 Pinecone, Weaviate 또는 Qdrant 인스턴스 없이 SQLite 내에서 벡터 KNN 검색을 추가합니다.4 49,746개 청크에서 쿼리 지연 시간은 23ms입니다.1 전용 벡터 데이터베이스는 83 MB에 맞는 단일 사용자 지식 베이스에 운영 복잡성(호스팅, 백업, 인증)을 추가할 것입니다.
OpenAI 임베딩이나 더 큰 모델 대신 Model2Vec를 사용하는 이유는?
세 가지 이유입니다: 지연 시간, 프라이버시, 비용. Model2Vec는 네트워크 호출 없이 CPU 속도로 로컬에서 실행됩니다.3 개인 노트는 절대 기기를 떠나지 않습니다. API 기반 임베딩은 현재 볼트 크기 기준 전체 재인덱싱당 약 $0.30의 비용이 듭니다.11 이것만 보면 사소하지만, 49,746개 청크에 걸친 왕복 지연 시간과 개인 콘텐츠의 프라이버시 노출이 실제 비용입니다.
Reciprocal Rank Fusion이란 무엇이며, 언제 사용해야 합니까?
RRF는 훈련 데이터, 점수 보정, 상수 k 이외의 하이퍼파라미터 튜닝이 필요 없습니다.7 학습된 퓨전 모델은 훈련을 위한 라벨링된 관련성 판단이 필요한데, 개인 지식 베이스에는 존재하지 않습니다. RRF는 유용한 결과를 생성하기 위한 진입 장벽이 가장 낮은 퓨전 방법입니다. 호환되지 않는 점수 유형을 생성하는 검색 방법의 순위 목록을 결합할 때 RRF를 사용하세요.
로컬 검색기는 Claude Code에 어떻게 연결됩니까?
PreToolUse 훅이 현재 프롬프트로 검색기의 search() 메서드를 호출하고, 상위 결과를 파일 경로와 섹션 제목이 포함된 컨텍스트 블록으로 포맷하여, 대화에 주입합니다. 에이전트는 원본 파일이 아닌 집중된 청크를 봅니다. max_tokens 파라미터가 주입된 컨텍스트가 예산 내에 맞도록 보장합니다.
검색 시스템에서 시크릿이 인덱싱되는 것을 어떻게 방지합니까?
저장 전 모든 청크에 크리덴셜 필터를 실행합니다. 이 시스템의 필터는 JWT, 베어러 토큰, 개인 키에 대한 21개의 벤더별 패턴과 11개의 일반 감지기를 매칭합니다.20 매칭된 콘텐츠를 [REDACTED:pattern-name] 토큰으로 대체하며, 임베딩 전에 실행되므로 벡터 표현이 크리덴셜 패턴을 절대 인코딩하지 않습니다.
참고 문헌
-
저자의 프로덕션 데이터. 49,746개 청크, 16,894개 파일, 83.56 MB SQLite 데이터베이스, 14개월에 걸쳐 처리된 7,771개 시그널. 쿼리 지연 시간(23ms)은 retriever.py에서
time.perf_counter()로 측정, 전체 검색 경로 포함: BM25 조회, Model2Vec를 통한 쿼리 임베딩, 벡터 KNN 검색, RRF 퓨전.grep -rl은 용어 빈도에 따라 11~66초 측정(Apple M3 Pro, APFS). 전체 재인덱싱은 Apple M3 Pro에서 ~4분 측정. 증분은 일반적인 일일 변경량에 대해 10초 미만 측정. 저자의 경우 FTS5 전용 검색은 파일 수가 ~3,000개를 넘어가면 키워드 충돌률로 인해 사용할 수 없게 되었음. ↩↩↩↩↩↩ -
HN 스레드: “Stop Burning Your Context Window”. danw1979와 tclancy의 상세 설명 요청 댓글. ↩
-
Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. potion-base-8M 모델은 문장 트랜스포머에서 증류된 정적 단어 임베딩을 사용하여, 어텐션 레이어를 실행하지 않고 256차원 벡터를 생성합니다. ↩↩↩
-
sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. 표준 테이블과 동일한 쿼리 인터페이스를 사용하여 SQLite 내에서 KNN 벡터 검색을 위한
vec0가상 테이블을 제공합니다. ↩↩↩ -
SQLite FTS5 Extension. SQLite 문서. FTS5는 BM25 랭킹, content-sync 테이블,
bm25()보조 함수를 통한 설정 가능한 컬럼 가중치가 포함된 전문 검색을 제공합니다. ↩↩↩ -
Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. 텍스트 검색을 위한 밀집 의미적 유사성에 대한 기초 연구로, 하이브리드 검색 시스템에서 사용되는 벡터 검색 접근 방식을 확립했습니다. ↩
-
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를 소개합니다. ↩↩↩↩
-
저자의 구현.
chunker.py는_split_at_headings함수에서 H2 경계에서 분할하며, 2,000자를 초과하는 섹션에 대해 H3, 그 다음 단락 분할로 폴백합니다. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000.index_vault.py는 64개 배치로 임베딩합니다(BATCH_SIZE=64). ↩↩ -
van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. 50~500배 추론 속도 향상으로 문장 트랜스포머에서 정적 임베딩을 생성하는 증류 접근 방식을 설명합니다. ↩↩↩
-
저자의 측정. 49,746개 청크에서 256차원 벡터는 83 MB SQLite를 생성합니다. 768차원 벡터로 외삽: ~215 MB. 1024차원으로: ~280 MB. 짧은 마크다운 청크(평균 200~400단어)에 대한 미미한 품질 향상은 저장소와 지연 시간 증가를 정당화하지 못합니다. ↩
-
OpenAI Embeddings Pricing. text-embedding-3-small: 백만 토큰당 $0.02. 전체 재인덱싱당 추정 볼트 비용: 평균 청크 길이 ~200 토큰 기준 ~$0.30. ↩↩
-
SQLite Write-Ahead Logging. SQLite 문서. WAL 모드는 단일 작성자와 동시 독자를 허용하며, 검색기의 읽기 중심 접근 패턴에 적합합니다. ↩
-
저자의 쿼리 추적. “PostToolUse hook for context compression”을 BM25 전용, 벡터 전용, 하이브리드 모드에 대해 실행. retriever.py에서 각 결과를 생성한 검색 경로를 추적하는
method필드로 결과를 캡처. ↩ -
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. BM25 계열 랭킹 함수와 그 이론적 기초에 대한 서베이. ↩↩
-
Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. 학습된 밀집 표현이 오픈 도메인 QA 벤치마크에서 BM25를 9~19% 능가하는 것을 입증하여, 어휘적 검색의 보완으로서 밀집 검색을 확립했습니다. ↩
-
Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. MS MARCO에서의 하이브리드 희소-밀집 검색 분석으로, 단일 모달리티 접근 방식에 비해 일관된 향상을 보여줍니다. ↩
-
MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M은 all-MiniLM-L6-v2 대비 MTEB 평균 50.03(56.09의 89.2% 유지). 작업별 분석: Classification 64.44, Clustering 32.93, Retrieval 31.71, STS 73.24. 출처: Model2Vec results. ↩
-
Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. 청킹 전략과 검색 품질에 미치는 영향 분석을 포함한 RAG 아키텍처 서베이. ↩
-
Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. 도메인별 검색 성능의 높은 분산을 입증하여, 도메인별 평가의 필요성을 강조합니다. ↩
-
저자의 설정 및 크리덴셜 필터 구현.
memory-config.json은 22개의allowed_folders와 5개의excluded_always항목을 정의합니다.credential_filter.py는 21개의 벤더별CREDENTIAL_PATTERNS(OpenAI부터 Turnstile까지)과 9개의 일반 단일행 패턴(DB URL, 베어러 토큰, JWT, 비밀번호, 시크릿, API 키, 인증 토큰, base64 시크릿) 및 2개의 다중행 패턴(RSA/SSH 개인 키, PGP 키)을 정의합니다. 총: 32개 패턴. ↩↩↩