vault-index 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vault_index-0.2.0/.gitignore +12 -0
- vault_index-0.2.0/PKG-INFO +48 -0
- vault_index-0.2.0/README.md +31 -0
- vault_index-0.2.0/pyproject.toml +42 -0
- vault_index-0.2.0/server.py +403 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
.export-stamp
|
|
7
|
+
# Your own MCP config (absolute paths); start from .mcp.json.example
|
|
8
|
+
.mcp.json
|
|
9
|
+
# Raw pre-compaction snapshots contain your prompts
|
|
10
|
+
docs/handoffs/auto/
|
|
11
|
+
.claude/settings.local.json
|
|
12
|
+
dist/
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vault-index
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Local hybrid (FTS5 + sqlite-vec) search MCP server for markdown vaults and codebases
|
|
5
|
+
Project-URL: Homepage, https://github.com/GitGudatCodeing/claude-second-brain
|
|
6
|
+
Project-URL: Issues, https://github.com/GitGudatCodeing/claude-second-brain/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: claude,markdown,mcp,obsidian,search,sqlite-vec
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
12
|
+
Requires-Python: <3.14,>=3.10
|
|
13
|
+
Requires-Dist: fastembed>=0.4
|
|
14
|
+
Requires-Dist: mcp<2,>=1.2
|
|
15
|
+
Requires-Dist: sqlite-vec>=0.1.6
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# vault-index
|
|
19
|
+
|
|
20
|
+
<!-- mcp-name: io.github.GitGudatCodeing/vault-index -->
|
|
21
|
+
|
|
22
|
+
Local hybrid search for markdown vaults and codebases, served as an MCP server. Keyword search (SQLite FTS5) and vector search (sqlite-vec, `bge-small-en-v1.5` on CPU) are merged with reciprocal rank fusion. Everything lives in one SQLite file on your machine; no cloud services.
|
|
23
|
+
|
|
24
|
+
Part of [claude-second-brain](https://github.com/GitGudatCodeing/claude-second-brain), where every search change is measured against golden question sets and compared with naive grep and basic-memory (see its README for the numbers).
|
|
25
|
+
|
|
26
|
+
## Use with Claude Code
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
claude mcp add vault-index -- uvx --python 3.12 vault-index
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then ask Claude to index a folder, for example: "index_paths ~/notes". After that, `search_notes` finds paraphrased answers with file path and heading. The first run downloads the embedding model (about 65 MB).
|
|
33
|
+
|
|
34
|
+
## Tools
|
|
35
|
+
- `index_paths(paths, reindex=False)`: add folders or files as sources and sync them. With no paths, re-syncs every source (new, changed and deleted files).
|
|
36
|
+
- `search_notes(query, limit=8, path_prefix=None)`: hybrid search; returns chunks with file path and heading path.
|
|
37
|
+
- `get_note(path)`: full text of an indexed file.
|
|
38
|
+
- `list_sources()`, `remove_source(path)`.
|
|
39
|
+
|
|
40
|
+
Markdown is chunked by heading. A `.indexignore` in a source folder lists relative paths to skip.
|
|
41
|
+
|
|
42
|
+
## Settings
|
|
43
|
+
| Env var | Default |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `VAULT_INDEX_DB` | `~/.mcp-memory/vault-index.db` |
|
|
46
|
+
| `VAULT_MODEL_CACHE` | `~/.mcp-memory/models` |
|
|
47
|
+
|
|
48
|
+
Python 3.10 to 3.13 (`--python 3.12` keeps uvx off newer Pythons that onnxruntime does not support yet). MIT license.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# vault-index
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.GitGudatCodeing/vault-index -->
|
|
4
|
+
|
|
5
|
+
Local hybrid search for markdown vaults and codebases, served as an MCP server. Keyword search (SQLite FTS5) and vector search (sqlite-vec, `bge-small-en-v1.5` on CPU) are merged with reciprocal rank fusion. Everything lives in one SQLite file on your machine; no cloud services.
|
|
6
|
+
|
|
7
|
+
Part of [claude-second-brain](https://github.com/GitGudatCodeing/claude-second-brain), where every search change is measured against golden question sets and compared with naive grep and basic-memory (see its README for the numbers).
|
|
8
|
+
|
|
9
|
+
## Use with Claude Code
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add vault-index -- uvx --python 3.12 vault-index
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then ask Claude to index a folder, for example: "index_paths ~/notes". After that, `search_notes` finds paraphrased answers with file path and heading. The first run downloads the embedding model (about 65 MB).
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
- `index_paths(paths, reindex=False)`: add folders or files as sources and sync them. With no paths, re-syncs every source (new, changed and deleted files).
|
|
19
|
+
- `search_notes(query, limit=8, path_prefix=None)`: hybrid search; returns chunks with file path and heading path.
|
|
20
|
+
- `get_note(path)`: full text of an indexed file.
|
|
21
|
+
- `list_sources()`, `remove_source(path)`.
|
|
22
|
+
|
|
23
|
+
Markdown is chunked by heading. A `.indexignore` in a source folder lists relative paths to skip.
|
|
24
|
+
|
|
25
|
+
## Settings
|
|
26
|
+
| Env var | Default |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `VAULT_INDEX_DB` | `~/.mcp-memory/vault-index.db` |
|
|
29
|
+
| `VAULT_MODEL_CACHE` | `~/.mcp-memory/models` |
|
|
30
|
+
|
|
31
|
+
Python 3.10 to 3.13 (`--python 3.12` keeps uvx off newer Pythons that onnxruntime does not support yet). MIT license.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vault-index"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Local hybrid (FTS5 + sqlite-vec) search MCP server for markdown vaults and codebases"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10,<3.14"
|
|
8
|
+
keywords = ["mcp", "claude", "search", "sqlite-vec", "markdown", "obsidian"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3",
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
"Topic :: Text Processing :: Indexing",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"mcp>=1.2,<2",
|
|
16
|
+
"sqlite-vec>=0.1.6",
|
|
17
|
+
"fastembed>=0.4",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/GitGudatCodeing/claude-second-brain"
|
|
22
|
+
Issues = "https://github.com/GitGudatCodeing/claude-second-brain/issues"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
vault-index = "vault_index:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling>=1.27"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
# The repo runs server.py in place; the wheel ships it as the module vault_index.
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
bypass-selection = true
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
36
|
+
"server.py" = "vault_index.py"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["server.py", "README.md", "pyproject.toml"]
|
|
40
|
+
|
|
41
|
+
[tool.uv]
|
|
42
|
+
package = false # `uv run --project mcp` keeps using the source file, not an installed copy
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Vault index MCP server: hybrid keyword (FTS5) + vector (sqlite-vec) search.
|
|
2
|
+
|
|
3
|
+
Embeddings come from fastembed (ONNX, CPU). Everything lives in one SQLite file.
|
|
4
|
+
Env:
|
|
5
|
+
VAULT_INDEX_DB path to the SQLite file (default ~/.mcp-memory/vault-index.db)
|
|
6
|
+
VAULT_MODEL_CACHE fastembed model cache dir (default ~/.mcp-memory/models)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import sqlite_vec
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
|
|
21
|
+
MODEL_NAME = "BAAI/bge-small-en-v1.5"
|
|
22
|
+
DIM = 384
|
|
23
|
+
HOME = Path.home() / ".mcp-memory"
|
|
24
|
+
DB_PATH = Path(os.environ.get("VAULT_INDEX_DB", HOME / "vault-index.db"))
|
|
25
|
+
MODEL_CACHE = Path(os.environ.get("VAULT_MODEL_CACHE", HOME / "models"))
|
|
26
|
+
|
|
27
|
+
EXTENSIONS = {
|
|
28
|
+
".md", ".markdown", ".txt", ".rst",
|
|
29
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".cs", ".java", ".go", ".rs", ".sql",
|
|
30
|
+
".json", ".yaml", ".yml", ".toml", ".ps1", ".sh",
|
|
31
|
+
}
|
|
32
|
+
SKIP_DIRS = {
|
|
33
|
+
".git", "node_modules", ".venv", "venv", "__pycache__", "bin", "obj",
|
|
34
|
+
"dist", "build", ".idea", ".vs", ".next", ".obsidian", ".trash",
|
|
35
|
+
}
|
|
36
|
+
MAX_FILE_BYTES = 1_000_000
|
|
37
|
+
CHUNK_CHARS = 1200
|
|
38
|
+
|
|
39
|
+
mcp = FastMCP("vault-index")
|
|
40
|
+
_embedder = None
|
|
41
|
+
_db: sqlite3.Connection | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def log(msg: str) -> None:
|
|
45
|
+
print(msg, file=sys.stderr, flush=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def embedder():
|
|
49
|
+
global _embedder
|
|
50
|
+
if _embedder is None:
|
|
51
|
+
from fastembed import TextEmbedding
|
|
52
|
+
|
|
53
|
+
MODEL_CACHE.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
_embedder = TextEmbedding(MODEL_NAME, cache_dir=str(MODEL_CACHE))
|
|
55
|
+
return _embedder
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def embed(texts: list[str]) -> list[bytes]:
|
|
59
|
+
return [sqlite_vec.serialize_float32(v.tolist()) for v in embedder().embed(texts)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def db() -> sqlite3.Connection:
|
|
63
|
+
global _db
|
|
64
|
+
if _db is None:
|
|
65
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30)
|
|
67
|
+
con.execute("PRAGMA journal_mode=WAL") # the PostToolUse hook writes concurrently
|
|
68
|
+
con.enable_load_extension(True)
|
|
69
|
+
sqlite_vec.load(con)
|
|
70
|
+
con.enable_load_extension(False)
|
|
71
|
+
con.executescript(
|
|
72
|
+
f"""
|
|
73
|
+
CREATE TABLE IF NOT EXISTS sources(path TEXT PRIMARY KEY);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS files(
|
|
75
|
+
id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL,
|
|
76
|
+
mtime REAL NOT NULL, hash TEXT NOT NULL);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS chunks(
|
|
78
|
+
id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL,
|
|
79
|
+
ord INTEGER NOT NULL, heading TEXT NOT NULL, text TEXT NOT NULL);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS chunks_file ON chunks(file_id);
|
|
81
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading);
|
|
82
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{DIM}]);
|
|
83
|
+
"""
|
|
84
|
+
)
|
|
85
|
+
_db = con
|
|
86
|
+
return _db
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------- chunking ----------
|
|
90
|
+
|
|
91
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def chunk_text(text: str, is_markdown: bool) -> list[tuple[str, str]]:
|
|
95
|
+
"""Return (heading_path, chunk_text) pairs."""
|
|
96
|
+
sections: list[tuple[str, list[str]]] = []
|
|
97
|
+
stack: list[tuple[int, str]] = []
|
|
98
|
+
heading, buf = "", []
|
|
99
|
+
in_fence = False
|
|
100
|
+
for line in text.splitlines():
|
|
101
|
+
if line.lstrip().startswith("```"):
|
|
102
|
+
in_fence = not in_fence
|
|
103
|
+
m = None if (in_fence or not is_markdown) else _HEADING.match(line)
|
|
104
|
+
if m:
|
|
105
|
+
sections.append((heading, buf))
|
|
106
|
+
level = len(m.group(1))
|
|
107
|
+
while stack and stack[-1][0] >= level:
|
|
108
|
+
stack.pop()
|
|
109
|
+
stack.append((level, m.group(2).strip()))
|
|
110
|
+
heading, buf = " > ".join(h for _, h in stack), []
|
|
111
|
+
else:
|
|
112
|
+
buf.append(line)
|
|
113
|
+
sections.append((heading, buf))
|
|
114
|
+
|
|
115
|
+
out: list[tuple[str, str]] = []
|
|
116
|
+
for head, lines in sections:
|
|
117
|
+
body = "\n".join(lines).strip()
|
|
118
|
+
if not body:
|
|
119
|
+
continue
|
|
120
|
+
cur = ""
|
|
121
|
+
for para in re.split(r"\n\s*\n", body):
|
|
122
|
+
while len(para) > CHUNK_CHARS * 1.5: # very long paragraph/code block
|
|
123
|
+
cut = para.rfind("\n", 0, CHUNK_CHARS)
|
|
124
|
+
cut = cut if cut > CHUNK_CHARS // 2 else CHUNK_CHARS
|
|
125
|
+
if cur:
|
|
126
|
+
out.append((head, cur))
|
|
127
|
+
cur = ""
|
|
128
|
+
out.append((head, para[:cut].strip()))
|
|
129
|
+
para = para[cut:]
|
|
130
|
+
if cur and len(cur) + len(para) > CHUNK_CHARS:
|
|
131
|
+
out.append((head, cur))
|
|
132
|
+
cur = ""
|
|
133
|
+
cur = f"{cur}\n\n{para}" if cur else para
|
|
134
|
+
if cur.strip():
|
|
135
|
+
out.append((head, cur.strip()))
|
|
136
|
+
return [(h, t) for h, t in out if t.strip()]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------- indexing ----------
|
|
140
|
+
|
|
141
|
+
def load_ignores(root: Path) -> list[str]:
|
|
142
|
+
"""Source-relative paths (files or folders) listed in <source>/.indexignore."""
|
|
143
|
+
f = root / ".indexignore"
|
|
144
|
+
if not root.is_dir() or not f.is_file():
|
|
145
|
+
return []
|
|
146
|
+
lines = (l.split("#", 1)[0].strip().replace("\\", "/").strip("/") for l in f.read_text(encoding="utf-8").splitlines())
|
|
147
|
+
return [l.lower() for l in lines if l]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_ignored(p: Path, root: Path, ignores: list[str] | None = None) -> bool:
|
|
151
|
+
ignores = load_ignores(root) if ignores is None else ignores
|
|
152
|
+
try:
|
|
153
|
+
rel = p.relative_to(root).as_posix().lower()
|
|
154
|
+
except ValueError:
|
|
155
|
+
return False
|
|
156
|
+
return any(rel == ig or rel.startswith(ig + "/") for ig in ignores)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_files(root: Path):
|
|
160
|
+
if root.is_file():
|
|
161
|
+
yield root
|
|
162
|
+
return
|
|
163
|
+
ignores = load_ignores(root)
|
|
164
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
165
|
+
dirnames[:] = [
|
|
166
|
+
d for d in dirnames if d not in SKIP_DIRS and not is_ignored(Path(dirpath) / d, root, ignores)
|
|
167
|
+
]
|
|
168
|
+
for name in filenames:
|
|
169
|
+
p = Path(dirpath) / name
|
|
170
|
+
if p.suffix.lower() in EXTENSIONS and not is_ignored(p, root, ignores):
|
|
171
|
+
yield p
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def drop_file(con: sqlite3.Connection, file_id: int) -> None:
|
|
175
|
+
ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE file_id=?", (file_id,))]
|
|
176
|
+
for cid in ids:
|
|
177
|
+
con.execute("DELETE FROM chunks_fts WHERE rowid=?", (cid,))
|
|
178
|
+
con.execute("DELETE FROM chunks_vec WHERE rowid=?", (cid,))
|
|
179
|
+
con.execute("DELETE FROM chunks WHERE file_id=?", (file_id,))
|
|
180
|
+
con.execute("DELETE FROM files WHERE id=?", (file_id,))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def index_file(con: sqlite3.Connection, p: Path, force: bool) -> str:
|
|
184
|
+
key = str(p)
|
|
185
|
+
st = p.stat()
|
|
186
|
+
row = con.execute("SELECT id, mtime, hash FROM files WHERE path=?", (key,)).fetchone()
|
|
187
|
+
if row and not force and abs(row[1] - st.st_mtime) < 1e-6:
|
|
188
|
+
return "unchanged"
|
|
189
|
+
try:
|
|
190
|
+
text = p.read_text(encoding="utf-8") if st.st_size <= MAX_FILE_BYTES else None
|
|
191
|
+
except (UnicodeDecodeError, OSError):
|
|
192
|
+
text = None
|
|
193
|
+
if text is None: # too big or unreadable now: never keep serving its old chunks
|
|
194
|
+
if row:
|
|
195
|
+
drop_file(con, row[0])
|
|
196
|
+
return "skipped"
|
|
197
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
198
|
+
if row and not force and row[2] == digest:
|
|
199
|
+
con.execute("UPDATE files SET mtime=? WHERE id=?", (st.st_mtime, row[0]))
|
|
200
|
+
return "unchanged"
|
|
201
|
+
chunks = chunk_text(text, p.suffix.lower() in {".md", ".markdown"})
|
|
202
|
+
# Embed before touching the index, so a failure leaves the old version in place.
|
|
203
|
+
vecs = embed([f"{h}\n{t}" if h else t for h, t in chunks]) if chunks else []
|
|
204
|
+
if row:
|
|
205
|
+
drop_file(con, row[0])
|
|
206
|
+
cur = con.execute("INSERT INTO files(path, mtime, hash) VALUES(?,?,?)", (key, st.st_mtime, digest))
|
|
207
|
+
file_id = cur.lastrowid
|
|
208
|
+
if chunks:
|
|
209
|
+
for i, ((h, t), v) in enumerate(zip(chunks, vecs, strict=True)):
|
|
210
|
+
cid = con.execute(
|
|
211
|
+
"INSERT INTO chunks(file_id, ord, heading, text) VALUES(?,?,?,?)", (file_id, i, h, t)
|
|
212
|
+
).lastrowid
|
|
213
|
+
con.execute("INSERT INTO chunks_fts(rowid, text, heading) VALUES(?,?,?)", (cid, t, h))
|
|
214
|
+
con.execute("INSERT INTO chunks_vec(rowid, embedding) VALUES(?,?)", (cid, v))
|
|
215
|
+
return "updated" if row else "added"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sync_source(con: sqlite3.Connection, root: Path, force: bool) -> dict:
|
|
219
|
+
stats = {"added": 0, "updated": 0, "unchanged": 0, "skipped": 0, "removed": 0}
|
|
220
|
+
seen: set[str] = set()
|
|
221
|
+
try:
|
|
222
|
+
for p in iter_files(root):
|
|
223
|
+
seen.add(str(p))
|
|
224
|
+
stats[index_file(con, p, force)] += 1
|
|
225
|
+
for fid, path in files_under(con, str(root)):
|
|
226
|
+
if path not in seen:
|
|
227
|
+
drop_file(con, fid)
|
|
228
|
+
stats["removed"] += 1
|
|
229
|
+
except BaseException:
|
|
230
|
+
con.rollback() # the connection is long-lived; a later commit must never keep half a file
|
|
231
|
+
raise
|
|
232
|
+
con.commit()
|
|
233
|
+
return stats
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def files_under(con: sqlite3.Connection, source: str) -> list[tuple[int, str]]:
|
|
237
|
+
"""(id, path) of indexed files at or below `source`. A plain string match: LIKE treats _ and % as wildcards."""
|
|
238
|
+
prefix = source.rstrip("\\/")
|
|
239
|
+
return [(fid, path) for fid, path in con.execute("SELECT id, path FROM files").fetchall()
|
|
240
|
+
if path == prefix or path.startswith(prefix + os.sep)]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------- search ----------
|
|
244
|
+
|
|
245
|
+
STOPWORDS = frozenset("""
|
|
246
|
+
a about after again all am an and any are as at be because been before being both but by can could
|
|
247
|
+
did do does doing don't down during each few for from further had has have having he her here hers
|
|
248
|
+
him his how i if in into is it its itself just let me more most my no nor not of off on once only or
|
|
249
|
+
other our ours out over own same she should so some such than that the their theirs them then there
|
|
250
|
+
these they this those through to too under until up very was we were what when where which while who
|
|
251
|
+
whom why will with would you your yours
|
|
252
|
+
""".split())
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def fts_query(q: str) -> str | None:
|
|
256
|
+
toks = re.findall(r"\w+", q)
|
|
257
|
+
content = [t for t in toks if t.lower() not in STOPWORDS] or toks
|
|
258
|
+
return " OR ".join(f'"{t}"' for t in content) if content else None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.tool()
|
|
262
|
+
def index_paths(paths: list[str] | None = None, reindex: bool = False) -> str:
|
|
263
|
+
"""Add folders/files to the index (remembered as sources) and sync them.
|
|
264
|
+
|
|
265
|
+
With no `paths`, re-syncs every registered source (picks up new, changed and
|
|
266
|
+
deleted files). `reindex=True` forces re-embedding of everything.
|
|
267
|
+
"""
|
|
268
|
+
con = db()
|
|
269
|
+
for raw in paths or []:
|
|
270
|
+
p = Path(raw).expanduser().resolve()
|
|
271
|
+
if not p.exists():
|
|
272
|
+
return f"Path does not exist: {p}"
|
|
273
|
+
con.execute("INSERT OR IGNORE INTO sources(path) VALUES(?)", (str(p),))
|
|
274
|
+
con.commit()
|
|
275
|
+
sources = [r[0] for r in con.execute("SELECT path FROM sources ORDER BY path")]
|
|
276
|
+
if not sources:
|
|
277
|
+
return "No sources registered. Call index_paths with one or more paths."
|
|
278
|
+
targets = [str(Path(x).expanduser().resolve()) for x in paths] if paths else sources
|
|
279
|
+
lines = []
|
|
280
|
+
for s in targets:
|
|
281
|
+
lines.append(f"{s}: {sync_source(con, Path(s), reindex)}")
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
RRF_K = 60
|
|
286
|
+
# Keyword ranks count at a tenth of vector ranks: a tie-breaker and jargon booster, not an equal vote.
|
|
287
|
+
# Beat equal weights on both golden sets (docs/adr/2026-10-23-fts-weight-in-fusion.md).
|
|
288
|
+
FTS_WEIGHT = 0.1
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def hybrid_search(
|
|
292
|
+
query: str, limit: int, path_prefix: str | None = None, mode: str = "hybrid"
|
|
293
|
+
) -> list[tuple[str, str, str, float]]:
|
|
294
|
+
"""Ranked (path, heading, text, score). `mode` is hybrid, fts or vec; the
|
|
295
|
+
single-sided modes exist so eval/run_eval.py can measure each side's contribution."""
|
|
296
|
+
con = db()
|
|
297
|
+
pool = limit * 4
|
|
298
|
+
ranks: dict[int, float] = {}
|
|
299
|
+
# With a prefix, both sides rank only chunks under it, so a filtered search never comes back
|
|
300
|
+
# short just because other folders filled the candidate pool.
|
|
301
|
+
scope = json.dumps(prefix_chunk_ids(con, path_prefix)) if path_prefix else None
|
|
302
|
+
|
|
303
|
+
fq = fts_query(query)
|
|
304
|
+
if fq and mode in ("hybrid", "fts"):
|
|
305
|
+
sql = "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?"
|
|
306
|
+
args: tuple = (fq,)
|
|
307
|
+
if scope:
|
|
308
|
+
sql, args = sql + " AND rowid IN (SELECT value FROM json_each(?))", (fq, scope)
|
|
309
|
+
for rank, (cid,) in enumerate(con.execute(sql + " ORDER BY bm25(chunks_fts) LIMIT ?", (*args, pool))):
|
|
310
|
+
ranks[cid] = ranks.get(cid, 0) + (FTS_WEIGHT if mode == "hybrid" else 1) / (RRF_K + rank)
|
|
311
|
+
if mode in ("hybrid", "vec"):
|
|
312
|
+
qv = embed([query])[0]
|
|
313
|
+
if scope: # exact distances over the scoped chunks (vec0's kNN index cannot filter by path)
|
|
314
|
+
rows = con.execute(
|
|
315
|
+
"SELECT rowid FROM chunks_vec WHERE rowid IN (SELECT value FROM json_each(?))"
|
|
316
|
+
" ORDER BY vec_distance_l2(embedding, ?) LIMIT ?", (scope, qv, pool))
|
|
317
|
+
else:
|
|
318
|
+
rows = con.execute("SELECT rowid FROM chunks_vec WHERE embedding MATCH ? AND k=? ORDER BY distance", (qv, pool))
|
|
319
|
+
for rank, (cid,) in enumerate(rows):
|
|
320
|
+
ranks[cid] = ranks.get(cid, 0) + 1 / (RRF_K + rank)
|
|
321
|
+
|
|
322
|
+
results = []
|
|
323
|
+
for cid, score in sorted(ranks.items(), key=lambda kv: -kv[1])[:limit]:
|
|
324
|
+
path, heading, text = con.execute(
|
|
325
|
+
"SELECT f.path, c.heading, c.text FROM chunks c JOIN files f ON f.id=c.file_id WHERE c.id=?", (cid,)
|
|
326
|
+
).fetchone()
|
|
327
|
+
results.append((path, heading, text, score))
|
|
328
|
+
return results
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def prefix_chunk_ids(con: sqlite3.Connection, path_prefix: str) -> list[int]:
|
|
332
|
+
"""Ids of chunks whose file path starts with `path_prefix`: an absolute path, or one relative
|
|
333
|
+
to any registered source (e.g. "docs/lessons.md"). Case-insensitive; / and \\ are the same."""
|
|
334
|
+
def norm(s: str) -> str:
|
|
335
|
+
return s.replace("\\", "/").lower()
|
|
336
|
+
|
|
337
|
+
p = Path(path_prefix).expanduser()
|
|
338
|
+
if p.is_absolute():
|
|
339
|
+
bases = [norm(str(p))]
|
|
340
|
+
else:
|
|
341
|
+
rel = norm(path_prefix).removeprefix("./")
|
|
342
|
+
bases = [norm(s).rstrip("/") + "/" + rel for (s,) in con.execute("SELECT path FROM sources")]
|
|
343
|
+
rows = con.execute("SELECT c.id, f.path FROM chunks c JOIN files f ON f.id=c.file_id")
|
|
344
|
+
return [cid for cid, path in rows if norm(path).startswith(tuple(bases))]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@mcp.tool()
|
|
348
|
+
def search_notes(query: str, limit: int = 8, path_prefix: str | None = None) -> str:
|
|
349
|
+
"""Hybrid search (keyword + semantic, merged with reciprocal rank fusion).
|
|
350
|
+
|
|
351
|
+
Returns the best-matching chunks with file path and heading. Optionally restrict
|
|
352
|
+
to files whose path starts with `path_prefix`.
|
|
353
|
+
"""
|
|
354
|
+
limit = max(1, min(limit, 25))
|
|
355
|
+
results = []
|
|
356
|
+
for i, (path, heading, text, score) in enumerate(hybrid_search(query, limit, path_prefix), 1):
|
|
357
|
+
snippet = text if len(text) <= 700 else text[:700] + "..."
|
|
358
|
+
results.append(f"[{i}] {path}" + (f" # {heading}" if heading else "") + f" (score {score:.4f})\n{snippet}")
|
|
359
|
+
return "\n\n".join(results) or "No matches. (Has index_paths been run?)"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@mcp.tool()
|
|
363
|
+
def get_note(path: str) -> str:
|
|
364
|
+
"""Return the full text of an indexed file (read from disk)."""
|
|
365
|
+
p = Path(path).expanduser().resolve()
|
|
366
|
+
row = db().execute("SELECT 1 FROM files WHERE path=?", (str(p),)).fetchone()
|
|
367
|
+
if not row:
|
|
368
|
+
return f"Not in index: {p}. Only indexed files can be read with this tool."
|
|
369
|
+
return p.read_text(encoding="utf-8")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@mcp.tool()
|
|
373
|
+
def list_sources() -> str:
|
|
374
|
+
"""List registered source paths with file and chunk counts."""
|
|
375
|
+
con = db()
|
|
376
|
+
out = []
|
|
377
|
+
for (s,) in con.execute("SELECT path FROM sources ORDER BY path").fetchall():
|
|
378
|
+
ids = [fid for fid, _ in files_under(con, s)]
|
|
379
|
+
nc = sum(con.execute("SELECT COUNT(*) FROM chunks WHERE file_id=?", (fid,)).fetchone()[0] for fid in ids)
|
|
380
|
+
out.append(f"{s}: {len(ids)} files, {nc} chunks")
|
|
381
|
+
return "\n".join(out) or "No sources registered."
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@mcp.tool()
|
|
385
|
+
def remove_source(path: str) -> str:
|
|
386
|
+
"""Unregister a source and delete its files/chunks from the index (files on disk are untouched)."""
|
|
387
|
+
con = db()
|
|
388
|
+
s = str(Path(path).expanduser().resolve())
|
|
389
|
+
con.execute("DELETE FROM sources WHERE path=?", (s,))
|
|
390
|
+
ids = [fid for fid, _ in files_under(con, s)]
|
|
391
|
+
for fid in ids:
|
|
392
|
+
drop_file(con, fid)
|
|
393
|
+
con.commit()
|
|
394
|
+
return f"Removed {s} ({len(ids)} files dropped from index)."
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def main() -> None:
|
|
398
|
+
"""Console entry point (`uvx vault-index`): serve over stdio."""
|
|
399
|
+
mcp.run()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
main()
|