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 ADDED
@@ -0,0 +1,3 @@
1
+ """docsonar — local document search MCP server. Chat with your folders, fully offline."""
2
+
3
+ __version__ = "0.1.0"
docsonar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Support `python -m docsonar`."""
2
+
3
+ from docsonar.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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())