QMD - Query Markup Documents, On-device Search Engine for Your Own Documents
by NyangPolice
Introduction
연구 개발을 수행하다 보면 수많은 시행착오와 그 과정에서 얻은 지식을 문서로 기록하게 된다. 초창기에는 Notion을 주로 사용했는데, 진입 장벽이 낮았기 때문이다. 이미지를 넣기도 쉽고, 글을 multi column으로 만들기도 쉬우며, 워드와 마찬가지로 WYSIWYG, 즉 내가 쓴 글을 시각적으로 확인하기 쉬웠기 때문이다. 그러나 Notion은 이따금 화면이 로딩되지 않는 문제가 있었고, 개발한 프로그램의 Documentation을 Git Repository가 아닌 별도의 Notion에 기록해두었더니 나중에 사용법을 확인하기가 번거로웠다.
또한 개인 공부나 연구 내용을 기록하는 용도로는 Notion이 기능이 지나치게 많고 무겁게 느껴졌다. 이후 개인 노트는 Obsidian을 활용하게 되었고, 연구실 서버 및 서비스 관련 기록도 보안 및 편의성 측면에서 마크다운만 사용하도록 방향이 바뀌면서 점차 많은 마크다운 문서를 작성하게 되었다.
문제는 내가 생성한 문서의 내용을 전부 기억하기 어렵다는 것이다. 몇 년에 걸쳐 생성된 수많은 마크다운 문서에는 동일한 대상에 대해 명칭이 통일되어 있지 않기도 하고, 어떤 내용을 기록해두었더라도 정확히 어떤 명칭으로, 어떤 맥락에서 기록했는지 기억하기 어려웠다. 이때 QMD - Query Markup Documents라는 GitHub 프로젝트를 발견하였고, 사용해 본 결과 매우 만족스러워 이 블로그 글을 통해 사용법을 공유하고자 한다.
QMD의 작동 원리
이 섹션에서는 QMD의 작동 원리를 서술하며, 몰라도 사용에 지장은 없으므로 관심 없다면 다음 섹션으로 넘어가도 좋다.
QMD를 한줄로 요약하자면 내 문서들을 위한 온디바이스 검색 엔진 — 마크다운 노트, 회의록, 문서, 지식베이스를 인덱싱하고, 키워드 또는 자연어로 검색하는 로컬 실행 CLI 도구이다. QMD는 3가지 검색 기법을 이용해 로컬에서 검색을 수행한다. 위에서부터 각각 qmd search, qmd vsearch, ‘qmd query` 명령어에 해당한다.
| 기법 | 방식 | 특징 |
|---|---|---|
| BM25 (FTS5) | 전통적 키워드 검색 | 빠름, SQLite FTS5 기반 |
| Vector Search | 임베딩 기반 의미론적 검색 | 유사 의미 검색 가능 |
| LLM Re-ranking | LLM이 결과를 재정렬 | 품질 최상, 로컬 GGUF 모델 사용 |
QMD의 검색 파이프라인은 다음과 같다.
flowchart TB
Q([사용자 쿼리]) --> S & V & HY
subgraph S["qmd search — BM25"]
direction TB
S1["BM25 전문 검색
SQLite FTS5
키워드 정확 매칭"]
S2["점수 정규화
abs(BM25 score)
범위: 0 ~ 25+"]
S1 --> S2
end
subgraph V["qmd vsearch — Vector"]
direction TB
V1["쿼리 임베딩 생성
embeddinggemma-300M
고차원 벡터로 변환"]
V2["코사인 유사도 검색
sqlite-vec 인덱스"]
V3["점수 정규화
1 / (1 + cosine_dist)
범위: 0.0 ~ 1.0"]
V1 --> V2 --> V3
end
subgraph HY["qmd query — Hybrid + LLM"]
direction TB
H1["<b>[1] Query Expansion</b>
원본 ×2 + 변형 쿼리 2개"]
H2A["BM25 × 3"]
H2B["Vector × 3"]
H3["<b>[2] RRF Fusion</b>
Σ 1/(k+rank+1), k=60
상위 30개 선발"]
H4["<b>[3] LLM Re-ranking</b>
qwen3-reranker
yes/no + logprobs"]
H5["<b>[4] Position-Aware Blending</b>
1~3위: RRF 75% / Reranker 25%
4~10위: RRF 60% / Reranker 40%
11위~: RRF 40% / Reranker 60%"]
H1 --> H2A & H2B
H2A & H2B --> H3
H3 --> H4 --> H5
end
S2 --> R([최종 결과])
V3 --> R
H5 --> R
style Q fill:#f1efe8,stroke:#888780,color:#2C2C2A
style R fill:#f1efe8,stroke:#888780,color:#2C2C2A
style S fill:#e1f5ee,stroke:#1D9E75,color:#085041
style S1 fill:#e1f5ee,stroke:#1D9E75,color:#085041
style S2 fill:#e1f5ee,stroke:#1D9E75,color:#085041
style V fill:#e6f1fb,stroke:#378ADD,color:#0C447C
style V1 fill:#e6f1fb,stroke:#378ADD,color:#0C447C
style V2 fill:#e6f1fb,stroke:#378ADD,color:#0C447C
style V3 fill:#e6f1fb,stroke:#378ADD,color:#0C447C
style HY fill:#faeeda,stroke:#EF9F27,color:#412402
style H1 fill:#eeedfe,stroke:#7F77DD,color:#26215C
style H2A fill:#e1f5ee,stroke:#1D9E75,color:#085041
style H2B fill:#e6f1fb,stroke:#378ADD,color:#0C447C
style H3 fill:#faeeda,stroke:#EF9F27,color:#412402
style H4 fill:#faeeda,stroke:#EF9F27,color:#412402
style H5 fill:#faece7,stroke:#D85A30,color:#4A1B0C
용어에 대해서는 아래를 참조하면 된다.
SQLite FTS5
FTS5(Full-Text Search 5)는 SQLite에 내장된 전문검색 확장 모듈입니다.
일반적으로 SQL에서 특정 단어를 포함한 문서를 찾으려면 LIKE 연산자를 사용합니다.
SELECT * FROM documents WHERE content LIKE '%authentication%';
하지만 LIKE는 인덱스를 활용하지 못하고 테이블의 모든 행을 처음부터 끝까지 하나씩 비교합니다. 문서가 100만 개면 100만 번 비교하는 셈입니다. 게다가 단순히 포함 여부만 판단할 뿐 이 문서가 쿼리와 얼마나 관련 있는가라는 관련도 점수를 매기지 못해 결과를 의미 있게 정렬하기도 어렵습니다.
FTS5는 이 두 가지 문제를 모두 해결합니다. 문서 전체를 토큰 단위로 분해해서 역인덱스(inverted index) 를 미리 구축해두기 때문에 이 단어가 어느 문서에 있는가를 풀스캔 없이 인덱스 조회만으로 즉시 찾아낼 수 있습니다. 역인덱스란 “단어 → 해당 단어가 등장하는 문서 목록”을 미리 정리해둔 구조로, 사전의 색인과 같은 역할을 합니다.
관련도 점수 계산에는 BM25(Best Match 25) 알고리즘을 사용합니다. 단어가 해당 문서에 얼마나 자주 나오는지(TF, Term Frequency)와 전체 문서 중에서 얼마나 희귀한 단어인지(IDF, Inverse Document Frequency)를 조합해 관련도를 수치화합니다. 덕분에 검색 결과를 관련도 순으로 정렬할 수 있습니다.
별도의 검색 서버(Elasticsearch 등) 없이 SQLite 파일 하나만으로 전문검색을 구현할 수 있다는 것이 핵심입니다.
sqlite-vec
SQLite에 벡터 유사도 검색 기능을 추가하는 확장 모듈입니다. 텍스트를 임베딩 모델로 변환한 수백~수천 차원의 부동소수점 배열(벡터)을 저장하고, 주어진 쿼리 벡터와 가장 가까운 벡터들을 빠르게 찾아줍니다. 유사도 측정에는 코사인 유사도를 사용합니다. Pinecone, Weaviate 같은 별도의 벡터 DB 없이 SQLite 파일 안에서 벡터 검색을 처리할 수 있습니다. QMD가 완전 로컬, 단일 파일로 동작할 수 있는 핵심 이유 중 하나입니다.
수식에서 각 기호는 다음을 의미합니다.
- $A$ : 쿼리 텍스트를 embeddinggemma 모델로 변환한 벡터
- $B$ : 문서 청크를 embeddinggemma 모델로 변환한 벡터
- $A_i$, $B_i$ : 각 벡터의 $i$번째 성분
- $n$ : 벡터의 차원 수 (embeddinggemma-300M 기준 300차원)
예를 들어 "how to deploy"라는 쿼리와 "배포 방법은 다음과 같습니다"라는 문서 청크를 각각 300차원 벡터로 변환하면:
의미가 비슷한 문장일수록 비슷한 방향의 벡터로 변환되도록 임베딩 모델이 학습되어 있기 때문에, 두 벡터의 방향이 같다는 것은 의미가 유사하다는 뜻입니다.
\[\begin{align*} \text{cosine similarity} &:= \cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{\|\mathbf{A}\| \|\mathbf{B}\|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}} \in [-1, 1] \\ \text{cosine distance} &= 1 - \text{cosine similarity} \in [0, 1] \\ \text{score} &= \frac{1}{1 + \text{cosine distance}} \in [0, 1] \end{align*}\]cosine similarity의 이론적 범위는 $[-1, 1]$이지만, 텍스트 임베딩 모델은 벡터의 각 성분이 모두 양수가 되도록 학습되는 경우가 많아 실제로는 $[0, 1]$ 범위로 좁혀집니다. cosine distance는 similarity가 높을수록 거리가 가까워야 한다는 직관을 표현하기 위해 $1$에서 빼서 정의합니다.
RRF (Reciprocal Rank Fusion)
여러 검색 결과 목록을 하나로 합치는 알고리즘입니다. BM25 결과와 벡터 검색 결과는 점수 체계가 달라서 단순히 점수를 더할 수 없습니다. RRF는 점수 대신 순위만 사용해서 이 문제를 해결합니다.
\[\begin{align*} \text{RRF score} &= \sum_{r \in R} \frac{1}{k + \text{rank}_r + 1} \\ R &: \text{전체 결과 목록들의 집합} \\ k &= 60 \quad \text{(상위권 순위 차이를 완화하는 상수, 논문에서 제안된 기본값)} \\ \text{rank}_r &\in \{0, 1, 2, \ldots\} \quad \text{(각 결과 목록에서의 순위, 0부터 시작)} \end{align*}\]예를 들어 어떤 문서가 BM25에서 1위, 벡터 검색에서 3위라면:
\[\text{RRF score} = \frac{1}{60 + 0 + 1} + \frac{1}{60 + 2 + 1} = \frac{1}{61} + \frac{1}{63} \approx 0.0164 + 0.0159 = 0.0323\]절대 점수가 아닌 순위 기반이기 때문에 이질적인 검색 방식의 결과를 안정적으로 합칠 수 있습니다.
Logprobs (Log Probabilities)
LLM이 토큰을 생성할 때 내부적으로 각 후보 토큰에 확률을 부여합니다. 이 확률의 로그값이 logprobs입니다. 확률은 항상 $0 \leq P \leq 1$ 범위이므로 로그를 취하면 항상 $0$ 이하의 음수가 됩니다. 값이 $0$에 가까울수록 확률이 높고, 절댓값이 클수록 확률이 낮습니다.
QMD의 재정렬 단계에서 qwen3-reranker는 문서마다 “이 문서가 쿼리와 관련 있는가?”라는 질문에 yes 또는 no로 답하는데, 단순히 어느 쪽을 선택했는지가 아니라 yes 토큰에 부여된 logprob 값 자체를 관련도 점수로 사용합니다.
단순 yes/no 분류보다 훨씬 세밀한 점수를 얻을 수 있습니다.
Reranker (Re-ranking)
1차 검색(BM25, 벡터)으로 빠르게 후보를 추린 뒤, 2차로 더 정교하게 순위를 재조정하는 모델입니다. 크로스-인코더 방식으로 동작하는데, 쿼리와 문서를 따로 처리하는 임베딩 방식과 달리 쿼리와 문서를 함께 입력으로 받아 상호작용을 직접 학습합니다.
\[\begin{align*} \text{Bi-encoder} &: f(\mathbf{q}) \cdot f(\mathbf{d}) \quad \text{(빠르지만 정밀도 낮음)} \\ \text{Cross-encoder} &: f(\mathbf{q}, \mathbf{d}) \quad \text{(느리지만 정밀도 높음)} \end{align*}\]여기서 $\mathbf{q}$는 query, $\mathbf{d}$는 document를 의미합니다. Bi-encoder는 쿼리와 문서를 각각 독립적으로 인코딩한 뒤 두 벡터의 내적($\cdot$)으로 유사도를 계산합니다. 반면 Cross-encoder는 $f(\mathbf{q}, \mathbf{d})$처럼 쿼리와 문서를 하나의 쌍으로 묶어 함께 입력하기 때문에 두 텍스트 사이의 세밀한 상호작용까지 반영할 수 있습니다.
모든 문서에 reranker를 돌리면 너무 느리기 때문에, QMD는 1차 검색에서 상위 30개(QMD 설정값)만 추려낸 뒤 reranker를 적용하는 2단계 파이프라인 구조를 취합니다.
현재 작성중…
printf("코드블럭 폰트 테스트")
Tools 카테고리의 글 목록
-
-
QMD - Query Markup Documents, On-device Search Engine for Your Own Documents
-
-