docsonar 0.1.0__py3-none-any.whl
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.
- docsonar/__init__.py +3 -0
- docsonar/__main__.py +6 -0
- docsonar/chunking.py +234 -0
- docsonar/cli.py +48 -0
- docsonar/config.py +120 -0
- docsonar/db.py +162 -0
- docsonar/embeddings.py +108 -0
- docsonar/indexer.py +392 -0
- docsonar/parsers/__init__.py +44 -0
- docsonar/parsers/base.py +49 -0
- docsonar/parsers/docx.py +59 -0
- docsonar/parsers/html.py +85 -0
- docsonar/parsers/markdown.py +86 -0
- docsonar/parsers/pdf.py +45 -0
- docsonar/parsers/text.py +24 -0
- docsonar/py.typed +0 -0
- docsonar/search.py +348 -0
- docsonar/security.py +53 -0
- docsonar/server.py +434 -0
- docsonar/worker.py +147 -0
- docsonar-0.1.0.dist-info/METADATA +242 -0
- docsonar-0.1.0.dist-info/RECORD +25 -0
- docsonar-0.1.0.dist-info/WHEEL +4 -0
- docsonar-0.1.0.dist-info/entry_points.txt +2 -0
- docsonar-0.1.0.dist-info/licenses/LICENSE +21 -0
docsonar/__init__.py
ADDED
docsonar/__main__.py
ADDED
docsonar/chunking.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Structure-aware chunking.
|
|
2
|
+
|
|
3
|
+
Blocks are grouped greedily up to a token target, flushing early at
|
|
4
|
+
heading boundaries so chunks don't straddle sections. Adjacent chunks
|
|
5
|
+
within a section share a small overlap. Character offsets index into the
|
|
6
|
+
"extracted text" of the document (block texts joined by blank lines),
|
|
7
|
+
which is exactly what ``read_file`` returns for a parsed document.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from docsonar.parsers.base import Block
|
|
18
|
+
|
|
19
|
+
_BLOCK_JOINER = "\n\n"
|
|
20
|
+
_WORD = re.compile(r"\S+")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def estimate_tokens(text: str) -> int:
|
|
24
|
+
# Word count x 1.3 for normal prose; the chars/8 floor only wins on
|
|
25
|
+
# whitespace-free blobs (minified/base64), where "1 word" would let a
|
|
26
|
+
# megabyte of text pass as a single token.
|
|
27
|
+
return max(1, round(len(_WORD.findall(text)) * 1.3), len(text) // 8)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Chunk:
|
|
32
|
+
text: str
|
|
33
|
+
token_count: int
|
|
34
|
+
heading_path: str | None
|
|
35
|
+
page_start: int | None
|
|
36
|
+
page_end: int | None
|
|
37
|
+
char_start: int
|
|
38
|
+
char_end: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _Piece:
|
|
43
|
+
text: str
|
|
44
|
+
tokens: int
|
|
45
|
+
heading_path: tuple[str, ...]
|
|
46
|
+
page: int | None
|
|
47
|
+
char_start: int
|
|
48
|
+
char_end: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extracted_text(blocks: list[Block]) -> str:
|
|
52
|
+
"""The virtual document text that chunk char offsets index into."""
|
|
53
|
+
return _BLOCK_JOINER.join(b.text for b in blocks)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _split_oversized(
|
|
57
|
+
text: str,
|
|
58
|
+
base: int,
|
|
59
|
+
heading_path: tuple[str, ...],
|
|
60
|
+
page: int | None,
|
|
61
|
+
max_tokens: int,
|
|
62
|
+
overlap_tokens: int,
|
|
63
|
+
) -> list[_Piece]:
|
|
64
|
+
"""Split a single huge block into overlapping word windows.
|
|
65
|
+
|
|
66
|
+
A "word" longer than ``_MAX_PIECE_CHARS`` (minified/base64 blobs have
|
|
67
|
+
no whitespace, so the word regex sees one giant token) is chopped
|
|
68
|
+
into fixed char windows so no chunk can smuggle megabytes of text.
|
|
69
|
+
"""
|
|
70
|
+
words = list(_WORD.finditer(text))
|
|
71
|
+
window = max(1, int(max_tokens / 1.3))
|
|
72
|
+
step = max(1, window - int(overlap_tokens / 1.3))
|
|
73
|
+
max_piece_chars = max_tokens * 8
|
|
74
|
+
pieces: list[_Piece] = []
|
|
75
|
+
|
|
76
|
+
def add_piece(piece_text: str, start: int, end: int) -> None:
|
|
77
|
+
pieces.append(
|
|
78
|
+
_Piece(
|
|
79
|
+
text=piece_text,
|
|
80
|
+
tokens=estimate_tokens(piece_text),
|
|
81
|
+
heading_path=heading_path,
|
|
82
|
+
page=page,
|
|
83
|
+
char_start=base + start,
|
|
84
|
+
char_end=base + end,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
for i in range(0, len(words), step):
|
|
89
|
+
span = words[i : i + window]
|
|
90
|
+
start, end = span[0].start(), span[-1].end()
|
|
91
|
+
segment = text[start:end]
|
|
92
|
+
if len(segment) > max_piece_chars:
|
|
93
|
+
for j in range(0, len(segment), max_piece_chars):
|
|
94
|
+
part = segment[j : j + max_piece_chars]
|
|
95
|
+
add_piece(part, start + j, start + j + len(part))
|
|
96
|
+
else:
|
|
97
|
+
add_piece(segment, start, end)
|
|
98
|
+
if i + window >= len(words):
|
|
99
|
+
break
|
|
100
|
+
return pieces
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def chunk_blocks(
|
|
104
|
+
blocks: list[Block],
|
|
105
|
+
*,
|
|
106
|
+
target_tokens: int = 400,
|
|
107
|
+
max_tokens: int = 512,
|
|
108
|
+
min_tokens: int = 100,
|
|
109
|
+
overlap_tokens: int = 60,
|
|
110
|
+
) -> list[Chunk]:
|
|
111
|
+
# A pathological config (overlap >= target) would make the overlap carry
|
|
112
|
+
# dominate every chunk and _split_oversized degenerate to step=1.
|
|
113
|
+
overlap_tokens = max(0, min(overlap_tokens, target_tokens // 2))
|
|
114
|
+
|
|
115
|
+
pieces: list[_Piece] = []
|
|
116
|
+
offset = 0
|
|
117
|
+
for block in blocks:
|
|
118
|
+
tokens = estimate_tokens(block.text)
|
|
119
|
+
if tokens > max_tokens or len(block.text) > max_tokens * 8:
|
|
120
|
+
pieces.extend(
|
|
121
|
+
_split_oversized(
|
|
122
|
+
block.text,
|
|
123
|
+
offset,
|
|
124
|
+
block.heading_path,
|
|
125
|
+
block.page,
|
|
126
|
+
max_tokens,
|
|
127
|
+
overlap_tokens,
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
pieces.append(
|
|
132
|
+
_Piece(
|
|
133
|
+
text=block.text,
|
|
134
|
+
tokens=tokens,
|
|
135
|
+
heading_path=block.heading_path,
|
|
136
|
+
page=block.page,
|
|
137
|
+
char_start=offset,
|
|
138
|
+
char_end=offset + len(block.text),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
offset += len(block.text) + len(_BLOCK_JOINER)
|
|
142
|
+
|
|
143
|
+
chunks: list[Chunk] = []
|
|
144
|
+
current: list[_Piece] = []
|
|
145
|
+
current_tokens = 0
|
|
146
|
+
# True while `current` holds only overlap carried from the previous flush;
|
|
147
|
+
# such a remainder is already inside the last chunk and must not re-emit.
|
|
148
|
+
carried_only = False
|
|
149
|
+
|
|
150
|
+
def flush(carry_overlap: bool) -> None:
|
|
151
|
+
nonlocal current, current_tokens, carried_only
|
|
152
|
+
if not current:
|
|
153
|
+
return
|
|
154
|
+
chunks.append(_build_chunk(current))
|
|
155
|
+
carried_only = True
|
|
156
|
+
if carry_overlap:
|
|
157
|
+
# Seed the next chunk with trailing pieces, but never re-carry
|
|
158
|
+
# everything (that would loop forever on tiny sections).
|
|
159
|
+
carried: list[_Piece] = []
|
|
160
|
+
carried_tokens = 0
|
|
161
|
+
for piece in reversed(current):
|
|
162
|
+
if carried_tokens + piece.tokens > overlap_tokens or len(
|
|
163
|
+
carried
|
|
164
|
+
) + 1 >= len(current):
|
|
165
|
+
break
|
|
166
|
+
carried.insert(0, piece)
|
|
167
|
+
carried_tokens += piece.tokens
|
|
168
|
+
current = carried
|
|
169
|
+
current_tokens = carried_tokens
|
|
170
|
+
else:
|
|
171
|
+
current = []
|
|
172
|
+
current_tokens = 0
|
|
173
|
+
|
|
174
|
+
for piece in pieces:
|
|
175
|
+
section_changed = (
|
|
176
|
+
bool(current) and piece.heading_path != current[-1].heading_path
|
|
177
|
+
)
|
|
178
|
+
if current and (
|
|
179
|
+
current_tokens + piece.tokens > max_tokens
|
|
180
|
+
or (section_changed and current_tokens >= min_tokens)
|
|
181
|
+
):
|
|
182
|
+
flush(carry_overlap=not section_changed)
|
|
183
|
+
current.append(piece)
|
|
184
|
+
current_tokens += piece.tokens
|
|
185
|
+
carried_only = False
|
|
186
|
+
if current_tokens >= target_tokens:
|
|
187
|
+
flush(carry_overlap=True)
|
|
188
|
+
|
|
189
|
+
# Trailing remainder: merge into the previous chunk when it's tiny and fits.
|
|
190
|
+
# Skip it entirely when it is nothing but carried overlap — that text is
|
|
191
|
+
# already part of the last emitted chunk.
|
|
192
|
+
if current and not carried_only:
|
|
193
|
+
remainder = _build_chunk(current)
|
|
194
|
+
if (
|
|
195
|
+
chunks
|
|
196
|
+
and remainder.token_count < min_tokens
|
|
197
|
+
and chunks[-1].token_count + remainder.token_count <= max_tokens
|
|
198
|
+
and remainder.char_start >= chunks[-1].char_start
|
|
199
|
+
):
|
|
200
|
+
chunks[-1] = _merge(chunks[-1], remainder)
|
|
201
|
+
else:
|
|
202
|
+
chunks.append(remainder)
|
|
203
|
+
return chunks
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_chunk(pieces: list[_Piece]) -> Chunk:
|
|
207
|
+
text = _BLOCK_JOINER.join(p.text for p in pieces)
|
|
208
|
+
heading = " > ".join(pieces[0].heading_path) or None
|
|
209
|
+
pages = [p.page for p in pieces if p.page is not None]
|
|
210
|
+
return Chunk(
|
|
211
|
+
text=text,
|
|
212
|
+
token_count=estimate_tokens(text),
|
|
213
|
+
heading_path=heading,
|
|
214
|
+
page_start=min(pages) if pages else None,
|
|
215
|
+
page_end=max(pages) if pages else None,
|
|
216
|
+
char_start=pieces[0].char_start,
|
|
217
|
+
char_end=pieces[-1].char_end,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _merge(a: Chunk, b: Chunk) -> Chunk:
|
|
222
|
+
text = a.text + _BLOCK_JOINER + b.text
|
|
223
|
+
pages = [
|
|
224
|
+
p for p in (a.page_start, a.page_end, b.page_start, b.page_end) if p is not None
|
|
225
|
+
]
|
|
226
|
+
return Chunk(
|
|
227
|
+
text=text,
|
|
228
|
+
token_count=estimate_tokens(text),
|
|
229
|
+
heading_path=a.heading_path,
|
|
230
|
+
page_start=min(pages) if pages else None,
|
|
231
|
+
page_end=max(pages) if pages else None,
|
|
232
|
+
char_start=a.char_start,
|
|
233
|
+
char_end=b.char_end,
|
|
234
|
+
)
|
docsonar/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Command-line entry point: `docsonar` (or `uvx docsonar`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from docsonar import __version__
|
|
9
|
+
from docsonar.config import default_config_path, default_db_path, load_config
|
|
10
|
+
from docsonar.server import create_server
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="docsonar",
|
|
16
|
+
description="Local document search MCP server — chat with your folders, fully offline.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--transport",
|
|
20
|
+
choices=["stdio", "http"],
|
|
21
|
+
default="stdio",
|
|
22
|
+
help="stdio for MCP clients like Claude Desktop/Code (default); "
|
|
23
|
+
"http for streamable HTTP",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (http only)")
|
|
26
|
+
parser.add_argument("--port", type=int, default=8365, help="HTTP port (http only)")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--db-path",
|
|
29
|
+
type=Path,
|
|
30
|
+
default=None,
|
|
31
|
+
help=f"index database location (default: {default_db_path()})",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--config",
|
|
35
|
+
type=Path,
|
|
36
|
+
default=None,
|
|
37
|
+
help=f"config.toml location (default: {default_config_path()})",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"docsonar {__version__}")
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
config = load_config(db_path=args.db_path, config_path=args.config)
|
|
43
|
+
server = create_server(config, host=args.host, port=args.port)
|
|
44
|
+
server.run(transport="stdio" if args.transport == "stdio" else "streamable-http")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
main()
|
docsonar/config.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Configuration: zero-config defaults, optionally overridden by a
|
|
2
|
+
``config.toml`` in the platform config directory.
|
|
3
|
+
|
|
4
|
+
Windows: %LOCALAPPDATA%\\docsonar\\config.toml
|
|
5
|
+
macOS: ~/Library/Application Support/docsonar/config.toml
|
|
6
|
+
Linux: ~/.config/docsonar/config.toml
|
|
7
|
+
|
|
8
|
+
Recognized keys (all optional)::
|
|
9
|
+
|
|
10
|
+
embedding_model = "BAAI/bge-small-en-v1.5"
|
|
11
|
+
chunk_target_tokens = 400
|
|
12
|
+
chunk_max_tokens = 512
|
|
13
|
+
chunk_min_tokens = 100
|
|
14
|
+
chunk_overlap_tokens = 60
|
|
15
|
+
max_file_size_mb = 50
|
|
16
|
+
extra_ignore_dirs = ["Archive", "tmp"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
import tomllib
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import platformdirs
|
|
28
|
+
|
|
29
|
+
APP_NAME = "docsonar"
|
|
30
|
+
|
|
31
|
+
# Directories never worth indexing, pruned during folder walks.
|
|
32
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
|
|
33
|
+
{
|
|
34
|
+
".git",
|
|
35
|
+
".hg",
|
|
36
|
+
".svn",
|
|
37
|
+
".venv",
|
|
38
|
+
"venv",
|
|
39
|
+
"node_modules",
|
|
40
|
+
"__pycache__",
|
|
41
|
+
".idea",
|
|
42
|
+
".vscode",
|
|
43
|
+
".cache",
|
|
44
|
+
".mypy_cache",
|
|
45
|
+
".ruff_cache",
|
|
46
|
+
".pytest_cache",
|
|
47
|
+
"dist",
|
|
48
|
+
"build",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_db_path() -> Path:
|
|
54
|
+
# appauthor=False: avoid Windows' doubled <author>\<app> nesting
|
|
55
|
+
return Path(platformdirs.user_data_dir(APP_NAME, appauthor=False)) / "index.db"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def default_config_path() -> Path:
|
|
59
|
+
return Path(platformdirs.user_config_dir(APP_NAME, appauthor=False)) / "config.toml"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Config:
|
|
64
|
+
"""Runtime configuration with zero-config defaults."""
|
|
65
|
+
|
|
66
|
+
db_path: Path = field(default_factory=default_db_path)
|
|
67
|
+
embedding_model: str = "BAAI/bge-small-en-v1.5"
|
|
68
|
+
chunk_target_tokens: int = 400
|
|
69
|
+
chunk_max_tokens: int = 512
|
|
70
|
+
chunk_min_tokens: int = 100
|
|
71
|
+
chunk_overlap_tokens: int = 60
|
|
72
|
+
max_file_size_bytes: int = 50 * 1024 * 1024
|
|
73
|
+
ignore_dirs: frozenset[str] = DEFAULT_IGNORE_DIRS
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_INT_KEYS = (
|
|
77
|
+
"chunk_target_tokens",
|
|
78
|
+
"chunk_max_tokens",
|
|
79
|
+
"chunk_min_tokens",
|
|
80
|
+
"chunk_overlap_tokens",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config(db_path: Path | None = None, config_path: Path | None = None) -> Config:
|
|
85
|
+
"""Build the effective config: defaults <- config.toml <- CLI overrides.
|
|
86
|
+
|
|
87
|
+
A malformed config file is reported on stderr and otherwise ignored —
|
|
88
|
+
the server must always come up.
|
|
89
|
+
"""
|
|
90
|
+
path = config_path if config_path is not None else default_config_path()
|
|
91
|
+
overrides: dict[str, Any] = {}
|
|
92
|
+
if path.is_file():
|
|
93
|
+
try:
|
|
94
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
95
|
+
overrides = _validate(data, path)
|
|
96
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
97
|
+
print(f"docsonar: ignoring config file {path}: {exc}", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
if db_path is not None:
|
|
100
|
+
overrides["db_path"] = db_path.expanduser().resolve()
|
|
101
|
+
return Config(**overrides)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _validate(data: dict[str, Any], path: Path) -> dict[str, Any]:
|
|
105
|
+
out: dict[str, Any] = {}
|
|
106
|
+
for key, value in data.items():
|
|
107
|
+
if (key in _INT_KEYS and isinstance(value, int)) or (
|
|
108
|
+
key == "embedding_model" and isinstance(value, str)
|
|
109
|
+
):
|
|
110
|
+
out[key] = value
|
|
111
|
+
elif key == "max_file_size_mb" and isinstance(value, int):
|
|
112
|
+
out["max_file_size_bytes"] = value * 1024 * 1024
|
|
113
|
+
elif key == "extra_ignore_dirs" and isinstance(value, list):
|
|
114
|
+
out["ignore_dirs"] = DEFAULT_IGNORE_DIRS | frozenset(str(v) for v in value)
|
|
115
|
+
else:
|
|
116
|
+
print(
|
|
117
|
+
f"docsonar: ignoring unknown or invalid config key {key!r} in {path}",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
return out
|
docsonar/db.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""SQLite storage: schema, connections, and small helpers.
|
|
2
|
+
|
|
3
|
+
One database file holds everything — folder registry, file metadata,
|
|
4
|
+
chunks, and the FTS5 index. WAL mode
|
|
5
|
+
keeps concurrent reads cheap and plays well with Windows file locking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = "1"
|
|
16
|
+
|
|
17
|
+
_SCHEMA = """
|
|
18
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
19
|
+
key TEXT PRIMARY KEY,
|
|
20
|
+
value TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
24
|
+
id INTEGER PRIMARY KEY,
|
|
25
|
+
path TEXT NOT NULL UNIQUE,
|
|
26
|
+
include_globs TEXT,
|
|
27
|
+
exclude_globs TEXT,
|
|
28
|
+
added_at TEXT NOT NULL,
|
|
29
|
+
last_indexed_at TEXT
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
33
|
+
id INTEGER PRIMARY KEY,
|
|
34
|
+
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
|
35
|
+
path TEXT NOT NULL UNIQUE,
|
|
36
|
+
rel_path TEXT NOT NULL,
|
|
37
|
+
file_type TEXT NOT NULL,
|
|
38
|
+
size_bytes INTEGER NOT NULL,
|
|
39
|
+
mtime_ns INTEGER NOT NULL,
|
|
40
|
+
content_hash TEXT NOT NULL,
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'indexed',
|
|
42
|
+
error TEXT,
|
|
43
|
+
indexed_at TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_files_folder ON files(folder_id);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
48
|
+
id INTEGER PRIMARY KEY,
|
|
49
|
+
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
|
50
|
+
seq INTEGER NOT NULL,
|
|
51
|
+
text TEXT NOT NULL,
|
|
52
|
+
token_count INTEGER NOT NULL,
|
|
53
|
+
heading_path TEXT,
|
|
54
|
+
page_start INTEGER,
|
|
55
|
+
page_end INTEGER,
|
|
56
|
+
char_start INTEGER NOT NULL,
|
|
57
|
+
char_end INTEGER NOT NULL,
|
|
58
|
+
UNIQUE (file_id, seq)
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_file ON chunks(file_id);
|
|
61
|
+
|
|
62
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
63
|
+
text,
|
|
64
|
+
content='chunks',
|
|
65
|
+
content_rowid='id',
|
|
66
|
+
tokenize='porter unicode61'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
70
|
+
INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
|
|
71
|
+
END;
|
|
72
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
73
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
|
|
74
|
+
END;
|
|
75
|
+
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE OF text ON chunks BEGIN
|
|
76
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
|
|
77
|
+
INSERT INTO chunks_fts(rowid, text) VALUES (new.id, new.text);
|
|
78
|
+
END;
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def init_db(db_path: Path) -> None:
|
|
83
|
+
"""Create the database file and schema if missing. Idempotent."""
|
|
84
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
with connect(db_path) as conn:
|
|
86
|
+
conn.executescript(_SCHEMA)
|
|
87
|
+
conn.execute(
|
|
88
|
+
"INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
89
|
+
(SCHEMA_VERSION,),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextmanager
|
|
94
|
+
def connect(db_path: Path) -> Iterator[sqlite3.Connection]:
|
|
95
|
+
"""Open a connection with sane pragmas; commits on success, rolls back on error."""
|
|
96
|
+
conn = sqlite3.connect(db_path)
|
|
97
|
+
conn.row_factory = sqlite3.Row
|
|
98
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
99
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
100
|
+
conn.execute("PRAGMA busy_timeout = 5000")
|
|
101
|
+
_load_vec_extension(conn)
|
|
102
|
+
try:
|
|
103
|
+
yield conn
|
|
104
|
+
conn.commit()
|
|
105
|
+
except BaseException:
|
|
106
|
+
conn.rollback()
|
|
107
|
+
raise
|
|
108
|
+
finally:
|
|
109
|
+
conn.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_vec_extension(conn: sqlite3.Connection) -> bool:
|
|
113
|
+
"""Load sqlite-vec into this connection. False (never raises) on failure."""
|
|
114
|
+
try:
|
|
115
|
+
import sqlite_vec
|
|
116
|
+
|
|
117
|
+
conn.enable_load_extension(True)
|
|
118
|
+
sqlite_vec.load(conn)
|
|
119
|
+
conn.enable_load_extension(False)
|
|
120
|
+
return True
|
|
121
|
+
except Exception:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def vec_available(conn: sqlite3.Connection) -> bool:
|
|
126
|
+
"""True if the sqlite-vec extension is usable on this connection."""
|
|
127
|
+
try:
|
|
128
|
+
conn.execute("SELECT vec_version()")
|
|
129
|
+
return True
|
|
130
|
+
except sqlite3.OperationalError:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def ensure_vec_schema(conn: sqlite3.Connection, model_name: str, dim: int) -> None:
|
|
135
|
+
"""Create (or reset) the vector table for the given embedding model.
|
|
136
|
+
|
|
137
|
+
The model name and dimension are pinned in ``meta``. If the configured
|
|
138
|
+
model changed, existing vectors are useless — drop and start clean;
|
|
139
|
+
files re-embed as they are (re)indexed.
|
|
140
|
+
"""
|
|
141
|
+
row = conn.execute(
|
|
142
|
+
"SELECT value FROM meta WHERE key = 'embedding_model'"
|
|
143
|
+
).fetchone()
|
|
144
|
+
if row is not None and row["value"] != model_name:
|
|
145
|
+
conn.execute("DROP TABLE IF EXISTS chunk_vec")
|
|
146
|
+
conn.execute(
|
|
147
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS chunk_vec USING vec0("
|
|
148
|
+
f"chunk_id INTEGER PRIMARY KEY, embedding FLOAT[{dim}] distance_metric=cosine)"
|
|
149
|
+
)
|
|
150
|
+
conn.execute(
|
|
151
|
+
"INSERT OR REPLACE INTO meta (key, value) VALUES ('embedding_model', ?)", (model_name,)
|
|
152
|
+
)
|
|
153
|
+
conn.execute(
|
|
154
|
+
"INSERT OR REPLACE INTO meta (key, value) VALUES ('embedding_dim', ?)", (str(dim),)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def vec_table_exists(conn: sqlite3.Connection) -> bool:
|
|
159
|
+
row = conn.execute(
|
|
160
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chunk_vec'"
|
|
161
|
+
).fetchone()
|
|
162
|
+
return row is not None
|
docsonar/embeddings.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Local embeddings via sentence-transformers.
|
|
2
|
+
|
|
3
|
+
The model loads lazily on first use (never at server startup — MCP
|
|
4
|
+
clients expect a fast stdio handshake) and downloads on first run. Load
|
|
5
|
+
failures are captured, not raised: the server degrades to keyword-only
|
|
6
|
+
search and every affected tool response says so.
|
|
7
|
+
|
|
8
|
+
Default model: BAAI/bge-small-en-v1.5 — 384 dims, ~130 MB, noticeably
|
|
9
|
+
good retrieval quality.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL = "BAAI/bge-small-en-v1.5"
|
|
21
|
+
|
|
22
|
+
# BGE v1.5 English models want this prefix on queries (not passages).
|
|
23
|
+
_BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EmbedderLike(Protocol):
|
|
27
|
+
"""What search/indexing need from an embedder; tests supply fakes."""
|
|
28
|
+
|
|
29
|
+
model_name: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def error(self) -> str | None: ...
|
|
33
|
+
@property
|
|
34
|
+
def dim(self) -> int: ...
|
|
35
|
+
def encode_passages(self, texts: list[str]) -> list[bytes]: ...
|
|
36
|
+
def encode_query(self, text: str) -> bytes: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Embedder:
|
|
40
|
+
"""Lazy, thread-safe wrapper around a SentenceTransformer model."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
|
|
43
|
+
self.model_name = model_name
|
|
44
|
+
self._model: Any = None
|
|
45
|
+
self._dim: int = 0
|
|
46
|
+
self._error: str | None = None
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
|
|
49
|
+
def _ensure_loaded(self) -> None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
if self._model is not None or self._error is not None:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
from sentence_transformers import SentenceTransformer
|
|
55
|
+
|
|
56
|
+
model = SentenceTransformer(self.model_name, device="cpu")
|
|
57
|
+
# renamed in sentence-transformers 5.x; support both
|
|
58
|
+
get_dim = (
|
|
59
|
+
getattr(model, "get_embedding_dimension", None)
|
|
60
|
+
or model.get_sentence_embedding_dimension
|
|
61
|
+
)
|
|
62
|
+
self._dim = int(get_dim() or 0)
|
|
63
|
+
self._model = model
|
|
64
|
+
except Exception as exc: # any failure => degrade, never crash
|
|
65
|
+
self._error = (
|
|
66
|
+
f"could not load embedding model {self.model_name!r}: {exc}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def error(self) -> str | None:
|
|
71
|
+
"""None if the model is usable; otherwise why it is not."""
|
|
72
|
+
self._ensure_loaded()
|
|
73
|
+
return self._error
|
|
74
|
+
|
|
75
|
+
def status_nonblocking(self) -> str:
|
|
76
|
+
"""Current state without triggering a load (index_status must stay fast)."""
|
|
77
|
+
if self._model is not None:
|
|
78
|
+
return "ready"
|
|
79
|
+
if self._error is not None:
|
|
80
|
+
return f"error: {self._error}"
|
|
81
|
+
return "not loaded yet (loads on first index or semantic search)"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def dim(self) -> int:
|
|
85
|
+
self._ensure_loaded()
|
|
86
|
+
return self._dim
|
|
87
|
+
|
|
88
|
+
def _encode(self, texts: list[str]) -> np.ndarray:
|
|
89
|
+
self._ensure_loaded()
|
|
90
|
+
if self._model is None:
|
|
91
|
+
raise RuntimeError(self._error or "embedding model not loaded")
|
|
92
|
+
import numpy as np
|
|
93
|
+
|
|
94
|
+
vectors = self._model.encode(
|
|
95
|
+
texts,
|
|
96
|
+
normalize_embeddings=True,
|
|
97
|
+
convert_to_numpy=True,
|
|
98
|
+
show_progress_bar=False,
|
|
99
|
+
)
|
|
100
|
+
return np.asarray(vectors, dtype=np.float32)
|
|
101
|
+
|
|
102
|
+
def encode_passages(self, texts: list[str]) -> list[bytes]:
|
|
103
|
+
return [bytes(v.tobytes()) for v in self._encode(texts)]
|
|
104
|
+
|
|
105
|
+
def encode_query(self, text: str) -> bytes:
|
|
106
|
+
if "bge-" in self.model_name and "-en-" in self.model_name:
|
|
107
|
+
text = _BGE_QUERY_PREFIX + text
|
|
108
|
+
return bytes(self._encode([text])[0].tobytes())
|