docsif 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.
- docsif-0.1.0.dist-info/METADATA +393 -0
- docsif-0.1.0.dist-info/RECORD +86 -0
- docsif-0.1.0.dist-info/WHEEL +4 -0
- docsif-0.1.0.dist-info/entry_points.txt +3 -0
- docsif-0.1.0.dist-info/licenses/LICENSE +21 -0
- sif/__init__.py +24 -0
- sif/_version.py +3 -0
- sif/cli/__init__.py +11 -0
- sif/cli/commands/__init__.py +18 -0
- sif/cli/commands/bench.py +148 -0
- sif/cli/commands/collection.py +360 -0
- sif/cli/commands/context.py +154 -0
- sif/cli/commands/get.py +200 -0
- sif/cli/commands/index.py +294 -0
- sif/cli/commands/ls.py +67 -0
- sif/cli/commands/mcp.py +93 -0
- sif/cli/commands/pull.py +111 -0
- sif/cli/commands/search.py +574 -0
- sif/cli/config.py +206 -0
- sif/cli/formatters.py +269 -0
- sif/cli/main.py +205 -0
- sif/config/__init__.py +30 -0
- sif/config/constants.py +52 -0
- sif/config/settings.py +217 -0
- sif/core/__init__.py +21 -0
- sif/core/collection.py +161 -0
- sif/core/context.py +167 -0
- sif/core/document.py +125 -0
- sif/core/models.py +298 -0
- sif/database/__init__.py +23 -0
- sif/database/connection.py +139 -0
- sif/database/database.py +136 -0
- sif/database/migrations.py +187 -0
- sif/database/repositories.py +500 -0
- sif/database/repository.py +171 -0
- sif/database/schema.py +362 -0
- sif/embedding/__init__.py +22 -0
- sif/embedding/cache.py +196 -0
- sif/embedding/embedder.py +328 -0
- sif/embedding/factory.py +92 -0
- sif/embedding/manager.py +200 -0
- sif/embedding/model.py +126 -0
- sif/indexing/__init__.py +21 -0
- sif/indexing/chunker.py +226 -0
- sif/indexing/indexer.py +226 -0
- sif/indexing/parser.py +192 -0
- sif/indexing/scanner.py +148 -0
- sif/indexing/watcher.py +156 -0
- sif/mcp/README.md +258 -0
- sif/mcp/__init__.py +9 -0
- sif/mcp/claude_desktop_config.json +16 -0
- sif/mcp/cli.py +92 -0
- sif/mcp/protocol.py +336 -0
- sif/mcp/server.py +433 -0
- sif/mcp/server_http.py +54 -0
- sif/mcp/test_mcp.py +108 -0
- sif/mcp/tools.py +497 -0
- sif/mcp/transport_http.py +424 -0
- sif/mcp/transport_stdio.py +292 -0
- sif/mcp_server/__init__.py +26 -0
- sif/mcp_server/handlers.py +68 -0
- sif/mcp_server/server.py +58 -0
- sif/mcp_server/tools.py +216 -0
- sif/mcp_server/transport.py +221 -0
- sif/models/__init__.py +70 -0
- sif/models/collection.py +51 -0
- sif/models/context.py +45 -0
- sif/models/document.py +76 -0
- sif/models/download.py +208 -0
- sif/models/embedding.py +71 -0
- sif/models/search.py +92 -0
- sif/search/__init__.py +22 -0
- sif/search/benchmark.py +110 -0
- sif/search/bm25.py +216 -0
- sif/search/expansion.py +157 -0
- sif/search/hybrid.py +323 -0
- sif/search/rerank.py +390 -0
- sif/search/rrf.py +186 -0
- sif/search/snippets.py +130 -0
- sif/search/strategy.py +97 -0
- sif/search/vector.py +177 -0
- sif/utils/__init__.py +26 -0
- sif/utils/logging.py +143 -0
- sif/utils/paths.py +82 -0
- sif/utils/progress.py +83 -0
- sif/utils/text.py +128 -0
sif/database/schema.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Database schema definitions for SIF."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SchemaManager:
|
|
10
|
+
"""Manages database schema creation and migrations."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, db: sqlite3.Connection, embedding_dim: int = 384) -> None:
|
|
13
|
+
self.db = db
|
|
14
|
+
self.embedding_dim = embedding_dim
|
|
15
|
+
|
|
16
|
+
def create_all(self) -> None:
|
|
17
|
+
"""Create all database tables and indexes."""
|
|
18
|
+
self._create_collections_table()
|
|
19
|
+
self._create_documents_table()
|
|
20
|
+
self._create_document_chunks_table()
|
|
21
|
+
self._migrate_path_contexts()
|
|
22
|
+
self._create_contexts_table()
|
|
23
|
+
self._create_llm_cache_table()
|
|
24
|
+
self._create_fts_tables()
|
|
25
|
+
self._create_vector_tables()
|
|
26
|
+
self._create_indexes()
|
|
27
|
+
|
|
28
|
+
def _add_column_if_missing(self, table: str, column: str, dtype: str) -> None:
|
|
29
|
+
"""Add a column to a table if it does not already exist."""
|
|
30
|
+
cursor = self.db.execute(f"PRAGMA table_info({table})")
|
|
31
|
+
columns = {row["name"] for row in cursor.fetchall()}
|
|
32
|
+
if column not in columns:
|
|
33
|
+
self.db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {dtype}")
|
|
34
|
+
|
|
35
|
+
def _create_collections_table(self) -> None:
|
|
36
|
+
"""Create collections table."""
|
|
37
|
+
self.db.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS collections (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
name TEXT UNIQUE NOT NULL,
|
|
41
|
+
path TEXT NOT NULL,
|
|
42
|
+
pattern TEXT DEFAULT '**/*.md',
|
|
43
|
+
ignore_patterns TEXT DEFAULT '[]',
|
|
44
|
+
include_by_default INTEGER DEFAULT 1,
|
|
45
|
+
description TEXT,
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
updated_at TEXT NOT NULL,
|
|
48
|
+
last_indexed_at TEXT,
|
|
49
|
+
document_count INTEGER DEFAULT 0,
|
|
50
|
+
chunk_count INTEGER DEFAULT 0
|
|
51
|
+
)
|
|
52
|
+
""")
|
|
53
|
+
self._add_column_if_missing("collections", "pre_update_cmd", "TEXT")
|
|
54
|
+
|
|
55
|
+
def _create_documents_table(self) -> None:
|
|
56
|
+
"""Create documents table."""
|
|
57
|
+
self.db.execute("""
|
|
58
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
collection_id TEXT NOT NULL,
|
|
61
|
+
path TEXT NOT NULL,
|
|
62
|
+
filename TEXT NOT NULL,
|
|
63
|
+
title TEXT,
|
|
64
|
+
content TEXT NOT NULL,
|
|
65
|
+
checksum TEXT NOT NULL,
|
|
66
|
+
file_size INTEGER DEFAULT 0,
|
|
67
|
+
mtime REAL NOT NULL,
|
|
68
|
+
created_at TEXT NOT NULL,
|
|
69
|
+
updated_at TEXT NOT NULL,
|
|
70
|
+
metadata TEXT DEFAULT '{}',
|
|
71
|
+
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE CASCADE
|
|
72
|
+
)
|
|
73
|
+
""")
|
|
74
|
+
|
|
75
|
+
def _create_document_chunks_table(self) -> None:
|
|
76
|
+
"""Create document chunks table."""
|
|
77
|
+
self.db.execute("""
|
|
78
|
+
CREATE TABLE IF NOT EXISTS document_chunks (
|
|
79
|
+
id TEXT PRIMARY KEY,
|
|
80
|
+
document_id TEXT NOT NULL,
|
|
81
|
+
sequence INTEGER NOT NULL,
|
|
82
|
+
content TEXT NOT NULL,
|
|
83
|
+
start_pos INTEGER DEFAULT 0,
|
|
84
|
+
end_pos INTEGER DEFAULT 0,
|
|
85
|
+
token_count INTEGER DEFAULT 0,
|
|
86
|
+
embedding_id TEXT,
|
|
87
|
+
created_at TEXT NOT NULL,
|
|
88
|
+
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
|
89
|
+
)
|
|
90
|
+
""")
|
|
91
|
+
|
|
92
|
+
def _create_contexts_table(self) -> None:
|
|
93
|
+
"""Create unified contexts table."""
|
|
94
|
+
self.db.execute("""
|
|
95
|
+
CREATE TABLE IF NOT EXISTS contexts (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
target_id TEXT NOT NULL,
|
|
98
|
+
context_type TEXT NOT NULL CHECK(context_type IN ('path', 'collection', 'global')),
|
|
99
|
+
content TEXT NOT NULL,
|
|
100
|
+
created_at TEXT NOT NULL,
|
|
101
|
+
updated_at TEXT NOT NULL
|
|
102
|
+
)
|
|
103
|
+
""")
|
|
104
|
+
|
|
105
|
+
def _migrate_path_contexts(self) -> None:
|
|
106
|
+
"""Migrate path_contexts -> contexts atomically using SAVEPOINT."""
|
|
107
|
+
cursor = self.db.execute(
|
|
108
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='path_contexts'"
|
|
109
|
+
)
|
|
110
|
+
if not cursor.fetchone():
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self.db.execute("SAVEPOINT sp_migration")
|
|
114
|
+
try:
|
|
115
|
+
self._create_contexts_table()
|
|
116
|
+
self.db.execute("""
|
|
117
|
+
INSERT INTO contexts (id, target_id, context_type, content, created_at, updated_at)
|
|
118
|
+
SELECT id, path, 'path', context, created_at, updated_at
|
|
119
|
+
FROM path_contexts
|
|
120
|
+
""")
|
|
121
|
+
self.db.execute("DROP TABLE path_contexts")
|
|
122
|
+
self.db.execute("""
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_contexts_target
|
|
124
|
+
ON contexts(target_id, context_type)
|
|
125
|
+
""")
|
|
126
|
+
self.db.execute("RELEASE SAVEPOINT sp_migration")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
self.db.execute("ROLLBACK TO SAVEPOINT sp_migration")
|
|
129
|
+
import sys # noqa: PLC0415
|
|
130
|
+
|
|
131
|
+
print(f"Migration failed: {e}", file=sys.stderr)
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
def _create_llm_cache_table(self) -> None:
|
|
135
|
+
"""Create LLM cache table."""
|
|
136
|
+
self.db.execute("""
|
|
137
|
+
CREATE TABLE IF NOT EXISTS llm_cache (
|
|
138
|
+
id TEXT PRIMARY KEY,
|
|
139
|
+
model_name TEXT NOT NULL,
|
|
140
|
+
prompt_hash TEXT NOT NULL,
|
|
141
|
+
prompt TEXT NOT NULL,
|
|
142
|
+
response TEXT NOT NULL,
|
|
143
|
+
created_at TEXT NOT NULL,
|
|
144
|
+
expires_at TEXT,
|
|
145
|
+
UNIQUE(model_name, prompt_hash)
|
|
146
|
+
)
|
|
147
|
+
""")
|
|
148
|
+
|
|
149
|
+
def _create_fts_tables(self) -> None:
|
|
150
|
+
"""Create FTS5 virtual tables for full-text search with triggers."""
|
|
151
|
+
|
|
152
|
+
def _fts_is_misconfigured(table_name: str, expected_content: str) -> bool:
|
|
153
|
+
"""Check if an existing FTS5 table lacks the correct content= setting."""
|
|
154
|
+
cursor = self.db.execute(
|
|
155
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
|
156
|
+
)
|
|
157
|
+
row = cursor.fetchone()
|
|
158
|
+
if not row:
|
|
159
|
+
return False # table does not exist; not misconfigured
|
|
160
|
+
sql = row[0] or ""
|
|
161
|
+
return f"content='{expected_content}'" not in sql
|
|
162
|
+
|
|
163
|
+
needs_rebuild_docs = _fts_is_misconfigured("documents_fts", "documents")
|
|
164
|
+
needs_rebuild_chunks = _fts_is_misconfigured("chunks_fts", "document_chunks")
|
|
165
|
+
|
|
166
|
+
if needs_rebuild_docs:
|
|
167
|
+
self.db.execute("DROP TABLE IF EXISTS documents_fts")
|
|
168
|
+
if needs_rebuild_chunks:
|
|
169
|
+
self.db.execute("DROP TABLE IF EXISTS chunks_fts")
|
|
170
|
+
|
|
171
|
+
# Documents FTS table with external content
|
|
172
|
+
self.db.execute("""
|
|
173
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
|
174
|
+
content,
|
|
175
|
+
content='documents',
|
|
176
|
+
content_rowid='rowid',
|
|
177
|
+
tokenize='porter'
|
|
178
|
+
)
|
|
179
|
+
""")
|
|
180
|
+
|
|
181
|
+
# Chunks FTS table with external content
|
|
182
|
+
self.db.execute("""
|
|
183
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
184
|
+
content,
|
|
185
|
+
content='document_chunks',
|
|
186
|
+
content_rowid='rowid',
|
|
187
|
+
tokenize='porter'
|
|
188
|
+
)
|
|
189
|
+
""")
|
|
190
|
+
|
|
191
|
+
# Triggers for documents_fts
|
|
192
|
+
self.db.execute("""
|
|
193
|
+
CREATE TRIGGER IF NOT EXISTS documents_fts_insert
|
|
194
|
+
AFTER INSERT ON documents
|
|
195
|
+
BEGIN
|
|
196
|
+
INSERT INTO documents_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
197
|
+
END
|
|
198
|
+
""")
|
|
199
|
+
self.db.execute("""
|
|
200
|
+
CREATE TRIGGER IF NOT EXISTS documents_fts_update
|
|
201
|
+
AFTER UPDATE ON documents
|
|
202
|
+
BEGIN
|
|
203
|
+
UPDATE documents_fts SET content = new.content WHERE rowid = old.rowid;
|
|
204
|
+
END
|
|
205
|
+
""")
|
|
206
|
+
self.db.execute("""
|
|
207
|
+
CREATE TRIGGER IF NOT EXISTS documents_fts_delete
|
|
208
|
+
AFTER DELETE ON documents
|
|
209
|
+
BEGIN
|
|
210
|
+
DELETE FROM documents_fts WHERE rowid = old.rowid;
|
|
211
|
+
END
|
|
212
|
+
""")
|
|
213
|
+
|
|
214
|
+
# Triggers for chunks_fts
|
|
215
|
+
self.db.execute("""
|
|
216
|
+
CREATE TRIGGER IF NOT EXISTS chunks_fts_insert
|
|
217
|
+
AFTER INSERT ON document_chunks
|
|
218
|
+
BEGIN
|
|
219
|
+
INSERT INTO chunks_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
220
|
+
END
|
|
221
|
+
""")
|
|
222
|
+
self.db.execute("""
|
|
223
|
+
CREATE TRIGGER IF NOT EXISTS chunks_fts_update
|
|
224
|
+
AFTER UPDATE ON document_chunks
|
|
225
|
+
BEGIN
|
|
226
|
+
UPDATE chunks_fts SET content = new.content WHERE rowid = old.rowid;
|
|
227
|
+
END
|
|
228
|
+
""")
|
|
229
|
+
self.db.execute("""
|
|
230
|
+
CREATE TRIGGER IF NOT EXISTS chunks_fts_delete
|
|
231
|
+
AFTER DELETE ON document_chunks
|
|
232
|
+
BEGIN
|
|
233
|
+
DELETE FROM chunks_fts WHERE rowid = old.rowid;
|
|
234
|
+
END
|
|
235
|
+
""")
|
|
236
|
+
|
|
237
|
+
# Seed existing data only when tables were rebuilt
|
|
238
|
+
if needs_rebuild_docs:
|
|
239
|
+
self.db.execute("""
|
|
240
|
+
INSERT INTO documents_fts(rowid, content)
|
|
241
|
+
SELECT rowid, content FROM documents
|
|
242
|
+
""")
|
|
243
|
+
if needs_rebuild_chunks:
|
|
244
|
+
self.db.execute("""
|
|
245
|
+
INSERT INTO chunks_fts(rowid, content)
|
|
246
|
+
SELECT rowid, content FROM document_chunks
|
|
247
|
+
""")
|
|
248
|
+
|
|
249
|
+
def _create_vector_tables(self) -> None:
|
|
250
|
+
"""Create vector tables using sqlite-vec."""
|
|
251
|
+
try:
|
|
252
|
+
self.db.execute("SELECT vec_version()")
|
|
253
|
+
vec_available = True
|
|
254
|
+
except sqlite3.OperationalError:
|
|
255
|
+
vec_available = False
|
|
256
|
+
|
|
257
|
+
if not vec_available:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
cursor = self.db.execute(
|
|
261
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='document_embeddings'"
|
|
262
|
+
)
|
|
263
|
+
row = cursor.fetchone()
|
|
264
|
+
if row and row[0]:
|
|
265
|
+
match = re.search(r"FLOAT\[(\d+)\]", row[0])
|
|
266
|
+
if match:
|
|
267
|
+
existing_dim = int(match.group(1))
|
|
268
|
+
if existing_dim != self.embedding_dim:
|
|
269
|
+
raise RuntimeError(
|
|
270
|
+
f"Embedding dimension mismatch: database has FLOAT[{existing_dim}], "
|
|
271
|
+
f"but settings require FLOAT[{self.embedding_dim}]. "
|
|
272
|
+
f"Rebuild the index with 'sif cleanup' or delete the database file."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
self.db.execute(f"""
|
|
276
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS document_embeddings USING vec0(
|
|
277
|
+
embedding_id TEXT PRIMARY KEY,
|
|
278
|
+
document_id TEXT NOT NULL,
|
|
279
|
+
chunk_id TEXT,
|
|
280
|
+
embedding FLOAT[{self.embedding_dim}]
|
|
281
|
+
)
|
|
282
|
+
""")
|
|
283
|
+
|
|
284
|
+
def _create_indexes(self) -> None:
|
|
285
|
+
"""Create database indexes."""
|
|
286
|
+
# Documents indexes
|
|
287
|
+
self.db.execute("""
|
|
288
|
+
CREATE INDEX IF NOT EXISTS idx_documents_collection
|
|
289
|
+
ON documents(collection_id)
|
|
290
|
+
""")
|
|
291
|
+
self.db.execute("""
|
|
292
|
+
CREATE INDEX IF NOT EXISTS idx_documents_path
|
|
293
|
+
ON documents(path)
|
|
294
|
+
""")
|
|
295
|
+
self.db.execute("""
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_documents_checksum
|
|
297
|
+
ON documents(checksum)
|
|
298
|
+
""")
|
|
299
|
+
|
|
300
|
+
# Chunks indexes
|
|
301
|
+
self.db.execute("""
|
|
302
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_document
|
|
303
|
+
ON document_chunks(document_id)
|
|
304
|
+
""")
|
|
305
|
+
|
|
306
|
+
# Contexts indexes
|
|
307
|
+
self.db.execute("""
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_contexts_target
|
|
309
|
+
ON contexts(target_id, context_type)
|
|
310
|
+
""")
|
|
311
|
+
|
|
312
|
+
# LLM cache index
|
|
313
|
+
self.db.execute("""
|
|
314
|
+
CREATE INDEX IF NOT EXISTS idx_llm_cache_lookup
|
|
315
|
+
ON llm_cache(model_name, prompt_hash)
|
|
316
|
+
""")
|
|
317
|
+
|
|
318
|
+
def drop_all(self) -> None:
|
|
319
|
+
"""Drop all tables (for testing/reset)."""
|
|
320
|
+
tables = [
|
|
321
|
+
"document_embeddings",
|
|
322
|
+
"chunks_fts",
|
|
323
|
+
"documents_fts",
|
|
324
|
+
"document_chunks",
|
|
325
|
+
"documents",
|
|
326
|
+
"contexts",
|
|
327
|
+
"llm_cache",
|
|
328
|
+
"collections",
|
|
329
|
+
]
|
|
330
|
+
for table in tables:
|
|
331
|
+
try:
|
|
332
|
+
self.db.execute(f"DROP TABLE IF EXISTS {table}")
|
|
333
|
+
except sqlite3.OperationalError:
|
|
334
|
+
pass
|
|
335
|
+
self.db.commit()
|
|
336
|
+
|
|
337
|
+
def get_stats(self) -> dict:
|
|
338
|
+
"""Get database statistics."""
|
|
339
|
+
stats = {}
|
|
340
|
+
|
|
341
|
+
# Collection count
|
|
342
|
+
cursor = self.db.execute("SELECT COUNT(*) FROM collections")
|
|
343
|
+
stats["collections"] = cursor.fetchone()[0]
|
|
344
|
+
|
|
345
|
+
# Document count
|
|
346
|
+
cursor = self.db.execute("SELECT COUNT(*) FROM documents")
|
|
347
|
+
stats["documents"] = cursor.fetchone()[0]
|
|
348
|
+
|
|
349
|
+
# Chunk count
|
|
350
|
+
cursor = self.db.execute("SELECT COUNT(*) FROM document_chunks")
|
|
351
|
+
stats["chunks"] = cursor.fetchone()[0]
|
|
352
|
+
|
|
353
|
+
# Context count
|
|
354
|
+
cursor = self.db.execute("SELECT COUNT(*) FROM contexts")
|
|
355
|
+
stats["contexts"] = cursor.fetchone()[0]
|
|
356
|
+
|
|
357
|
+
# Total size
|
|
358
|
+
cursor = self.db.execute("SELECT SUM(file_size) FROM documents")
|
|
359
|
+
result = cursor.fetchone()[0]
|
|
360
|
+
stats["total_size_bytes"] = result or 0
|
|
361
|
+
|
|
362
|
+
return stats
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Embedding generation and management for SIF.
|
|
2
|
+
|
|
3
|
+
This package provides embedding functionality using the Factory pattern:
|
|
4
|
+
- Embedding model management
|
|
5
|
+
- Vector generation for documents
|
|
6
|
+
- Model loading and caching
|
|
7
|
+
- Support for GGUF models via llama-cpp-python
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sif.embedding.cache import EmbeddingCache
|
|
11
|
+
from sif.embedding.factory import EmbeddingModelFactory
|
|
12
|
+
from sif.embedding.manager import EmbeddingManager
|
|
13
|
+
from sif.embedding.model import EmbeddingModel, ModelType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"EmbeddingCache",
|
|
18
|
+
"EmbeddingManager",
|
|
19
|
+
"EmbeddingModel",
|
|
20
|
+
"EmbeddingModelFactory",
|
|
21
|
+
"ModelType",
|
|
22
|
+
]
|
sif/embedding/cache.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Embedding cache for storing and retrieving embeddings."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sif.utils.logging import get_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EmbeddingCache:
|
|
15
|
+
"""Cache for embeddings to avoid recomputation.
|
|
16
|
+
|
|
17
|
+
Uses SQLite for persistent storage of embeddings keyed by
|
|
18
|
+
content hash. This allows embeddings to be reused across
|
|
19
|
+
application restarts.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, cache_dir: Path | str) -> None:
|
|
23
|
+
"""Initialize embedding cache.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
cache_dir: Directory for cache storage
|
|
27
|
+
"""
|
|
28
|
+
self._cache_dir = Path(cache_dir)
|
|
29
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
self._db_path = self._cache_dir / "embeddings_cache.db"
|
|
32
|
+
self._init_db()
|
|
33
|
+
|
|
34
|
+
def _init_db(self) -> None:
|
|
35
|
+
"""Initialize the cache database."""
|
|
36
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
37
|
+
conn.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
39
|
+
content_hash TEXT PRIMARY KEY,
|
|
40
|
+
embedding TEXT NOT NULL,
|
|
41
|
+
model_id TEXT NOT NULL,
|
|
42
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
43
|
+
)
|
|
44
|
+
""")
|
|
45
|
+
|
|
46
|
+
# Create index for faster lookups
|
|
47
|
+
conn.execute("""
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_model
|
|
49
|
+
ON embeddings(model_id)
|
|
50
|
+
""")
|
|
51
|
+
|
|
52
|
+
conn.commit()
|
|
53
|
+
|
|
54
|
+
def _hash_content(self, content: str) -> str:
|
|
55
|
+
"""Create a hash of the content.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
content: Content to hash
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
SHA-256 hash of the content
|
|
62
|
+
"""
|
|
63
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
64
|
+
|
|
65
|
+
def get(self, content: str, model_id: str = "default") -> list[float] | None:
|
|
66
|
+
"""Get cached embedding for content.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
content: Content to look up
|
|
70
|
+
model_id: Model identifier for cache segmentation
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Cached embedding or None if not found
|
|
74
|
+
"""
|
|
75
|
+
content_hash = self._hash_content(content)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
79
|
+
cursor = conn.execute(
|
|
80
|
+
"SELECT embedding FROM embeddings WHERE content_hash = ? AND model_id = ?",
|
|
81
|
+
(content_hash, model_id),
|
|
82
|
+
)
|
|
83
|
+
row = cursor.fetchone()
|
|
84
|
+
|
|
85
|
+
if row:
|
|
86
|
+
return json.loads(row[0])
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.warning(f"Error reading from cache: {e}")
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def set(
|
|
93
|
+
self,
|
|
94
|
+
content: str,
|
|
95
|
+
embedding: list[float],
|
|
96
|
+
model_id: str = "default",
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Store embedding in cache.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
content: Content that was embedded
|
|
102
|
+
embedding: The embedding vector
|
|
103
|
+
model_id: Model identifier for cache segmentation
|
|
104
|
+
"""
|
|
105
|
+
content_hash = self._hash_content(content)
|
|
106
|
+
embedding_json = json.dumps(embedding)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
110
|
+
conn.execute(
|
|
111
|
+
"""
|
|
112
|
+
INSERT OR REPLACE INTO embeddings
|
|
113
|
+
(content_hash, embedding, model_id)
|
|
114
|
+
VALUES (?, ?, ?)
|
|
115
|
+
""",
|
|
116
|
+
(content_hash, embedding_json, model_id),
|
|
117
|
+
)
|
|
118
|
+
conn.commit()
|
|
119
|
+
except Exception as e:
|
|
120
|
+
logger.warning(f"Error writing to cache: {e}")
|
|
121
|
+
|
|
122
|
+
def get_batch(
|
|
123
|
+
self,
|
|
124
|
+
contents: list[str],
|
|
125
|
+
model_id: str = "default",
|
|
126
|
+
) -> list[list[float] | None]:
|
|
127
|
+
"""Get cached embeddings for multiple contents.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
contents: List of contents to look up
|
|
131
|
+
model_id: Model identifier
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of embeddings (None if not in cache)
|
|
135
|
+
"""
|
|
136
|
+
return [self.get(c, model_id) for c in contents]
|
|
137
|
+
|
|
138
|
+
def set_batch(
|
|
139
|
+
self,
|
|
140
|
+
contents: list[str],
|
|
141
|
+
embeddings: list[list[float]],
|
|
142
|
+
model_id: str = "default",
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Store multiple embeddings in cache.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
contents: List of contents
|
|
148
|
+
embeddings: List of embeddings
|
|
149
|
+
model_id: Model identifier
|
|
150
|
+
"""
|
|
151
|
+
for content, embedding in zip(contents, embeddings):
|
|
152
|
+
self.set(content, embedding, model_id)
|
|
153
|
+
|
|
154
|
+
def clear(self, model_id: str | None = None) -> int:
|
|
155
|
+
"""Clear cached embeddings.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
model_id: If specified, only clear for this model
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Number of entries cleared
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
165
|
+
if model_id:
|
|
166
|
+
cursor = conn.execute(
|
|
167
|
+
"DELETE FROM embeddings WHERE model_id = ?",
|
|
168
|
+
(model_id,),
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
cursor = conn.execute("DELETE FROM embeddings")
|
|
172
|
+
|
|
173
|
+
conn.commit()
|
|
174
|
+
return cursor.rowcount
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.warning(f"Error clearing cache: {e}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
def get_stats(self) -> dict:
|
|
180
|
+
"""Get cache statistics.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Dictionary with cache statistics
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
with sqlite3.connect(self._db_path) as conn:
|
|
187
|
+
cursor = conn.execute("SELECT COUNT(*), model_id FROM embeddings GROUP BY model_id")
|
|
188
|
+
rows = cursor.fetchall()
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
"total_entries": sum(r[0] for r in rows),
|
|
192
|
+
"by_model": {r[1]: r[0] for r in rows},
|
|
193
|
+
}
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning(f"Error getting cache stats: {e}")
|
|
196
|
+
return {"total_entries": 0, "by_model": {}}
|