contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Embedding cache for content-hash based deduplication.
|
|
2
|
+
|
|
3
|
+
Caches embeddings keyed by content hash to avoid re-embedding unchanged chunks.
|
|
4
|
+
Uses SQLite for persistence across indexing runs.
|
|
5
|
+
|
|
6
|
+
Cache strategy:
|
|
7
|
+
- Key: SHA-256 hash of chunk content (header + body)
|
|
8
|
+
- Value: 768-dim embedding vector (JSON-encoded)
|
|
9
|
+
- Lookup: O(1) with index on content_hash
|
|
10
|
+
- Eviction: None (cache grows unbounded - embeddings are small)
|
|
11
|
+
|
|
12
|
+
Performance impact:
|
|
13
|
+
- Cache hit: ~0.1ms lookup vs ~55ms embedding generation (550x faster)
|
|
14
|
+
- Storage: ~3KB per cached embedding (768 floats as JSON)
|
|
15
|
+
- On 10K chunks: ~30MB cache, saves ~9 minutes of embedding time
|
|
16
|
+
|
|
17
|
+
The cache table is separate from main schema to allow independent cleanup.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from contextual.core.errors import ErrorCode, StorageError, storage_context
|
|
27
|
+
from contextual.observability.logging import get_logger
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from sqlite3 import Connection
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# ============================================================================
|
|
36
|
+
# CACHE SCHEMA
|
|
37
|
+
# ============================================================================
|
|
38
|
+
|
|
39
|
+
CACHE_TABLE_DDL = """
|
|
40
|
+
CREATE TABLE IF NOT EXISTS embedding_cache (
|
|
41
|
+
content_hash TEXT PRIMARY KEY,
|
|
42
|
+
embedding TEXT NOT NULL,
|
|
43
|
+
model_name TEXT NOT NULL,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
hit_count INTEGER DEFAULT 0
|
|
46
|
+
) STRICT;
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
CACHE_INDEX_DDL = """
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_embedding_cache_model
|
|
51
|
+
ON embedding_cache(model_name, created_at);
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ============================================================================
|
|
56
|
+
# CACHE OPERATIONS
|
|
57
|
+
# ============================================================================
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def initialize_cache(conn: Connection) -> None:
|
|
61
|
+
"""Initialize embedding cache table and indexes.
|
|
62
|
+
|
|
63
|
+
Safe to call multiple times (uses IF NOT EXISTS).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
conn: SQLite connection.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
StorageError: If cache initialization fails.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
cursor = conn.cursor()
|
|
73
|
+
cursor.execute(CACHE_TABLE_DDL)
|
|
74
|
+
cursor.execute(CACHE_INDEX_DDL)
|
|
75
|
+
conn.commit()
|
|
76
|
+
|
|
77
|
+
logger.debug("Embedding cache initialized")
|
|
78
|
+
|
|
79
|
+
except sqlite3.Error as e:
|
|
80
|
+
msg = f"Failed to initialize embedding cache: {e}"
|
|
81
|
+
raise StorageError(
|
|
82
|
+
code=ErrorCode.STORAGE_SQLITE_SCHEMA_INVALID,
|
|
83
|
+
message=msg,
|
|
84
|
+
context=storage_context(table="embedding_cache"),
|
|
85
|
+
) from e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_cached_embedding(
|
|
89
|
+
conn: Connection,
|
|
90
|
+
content_hash: str,
|
|
91
|
+
model_name: str,
|
|
92
|
+
) -> list[float] | None:
|
|
93
|
+
"""Retrieve cached embedding by content hash.
|
|
94
|
+
|
|
95
|
+
Increments hit_count on cache hit for analytics.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
conn: SQLite connection.
|
|
99
|
+
content_hash: SHA-256 hash of content.
|
|
100
|
+
model_name: Embedding model identifier.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
768-dim embedding vector, or None if not cached.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
StorageError: If cache lookup fails.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
cursor = conn.cursor()
|
|
110
|
+
|
|
111
|
+
# Fetch cached embedding
|
|
112
|
+
cursor.execute(
|
|
113
|
+
"""
|
|
114
|
+
SELECT embedding
|
|
115
|
+
FROM embedding_cache
|
|
116
|
+
WHERE content_hash = ? AND model_name = ?
|
|
117
|
+
""",
|
|
118
|
+
(content_hash, model_name),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
row = cursor.fetchone()
|
|
122
|
+
|
|
123
|
+
if row is None:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
# Parse JSON embedding
|
|
127
|
+
embedding = json.loads(row[0])
|
|
128
|
+
|
|
129
|
+
# Increment hit count
|
|
130
|
+
cursor.execute(
|
|
131
|
+
"""
|
|
132
|
+
UPDATE embedding_cache
|
|
133
|
+
SET hit_count = hit_count + 1
|
|
134
|
+
WHERE content_hash = ?
|
|
135
|
+
""",
|
|
136
|
+
(content_hash,),
|
|
137
|
+
)
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
logger.debug(
|
|
141
|
+
"Cache hit",
|
|
142
|
+
content_hash=content_hash[:8],
|
|
143
|
+
model=model_name,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return embedding
|
|
147
|
+
|
|
148
|
+
except (sqlite3.Error, json.JSONDecodeError) as e:
|
|
149
|
+
msg = f"Failed to retrieve cached embedding: {e}"
|
|
150
|
+
raise StorageError(
|
|
151
|
+
code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
|
|
152
|
+
message=msg,
|
|
153
|
+
context=storage_context(
|
|
154
|
+
table="embedding_cache",
|
|
155
|
+
query="get_cached_embedding",
|
|
156
|
+
),
|
|
157
|
+
) from e
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def put_cached_embedding(
|
|
161
|
+
conn: Connection,
|
|
162
|
+
content_hash: str,
|
|
163
|
+
embedding: list[float],
|
|
164
|
+
model_name: str,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Store embedding in cache.
|
|
167
|
+
|
|
168
|
+
Uses INSERT OR REPLACE for idempotent caching.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
conn: SQLite connection.
|
|
172
|
+
content_hash: SHA-256 hash of content.
|
|
173
|
+
embedding: 768-dim embedding vector.
|
|
174
|
+
model_name: Embedding model identifier.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
StorageError: If cache write fails.
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
import time
|
|
181
|
+
|
|
182
|
+
cursor = conn.cursor()
|
|
183
|
+
now_ms = int(time.time() * 1000)
|
|
184
|
+
|
|
185
|
+
# Serialize embedding as JSON
|
|
186
|
+
embedding_json = json.dumps(embedding)
|
|
187
|
+
|
|
188
|
+
# Insert or replace
|
|
189
|
+
cursor.execute(
|
|
190
|
+
"""
|
|
191
|
+
INSERT OR REPLACE INTO embedding_cache
|
|
192
|
+
(content_hash, embedding, model_name, created_at, hit_count)
|
|
193
|
+
VALUES (?, ?, ?, ?, 0)
|
|
194
|
+
""",
|
|
195
|
+
(content_hash, embedding_json, model_name, now_ms),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
conn.commit()
|
|
199
|
+
|
|
200
|
+
logger.debug(
|
|
201
|
+
"Cache write",
|
|
202
|
+
content_hash=content_hash[:8],
|
|
203
|
+
model=model_name,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
except (sqlite3.Error, TypeError) as e:
|
|
207
|
+
msg = f"Failed to cache embedding: {e}"
|
|
208
|
+
raise StorageError(
|
|
209
|
+
code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
|
|
210
|
+
message=msg,
|
|
211
|
+
context=storage_context(
|
|
212
|
+
table="embedding_cache",
|
|
213
|
+
query="put_cached_embedding",
|
|
214
|
+
),
|
|
215
|
+
) from e
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_cached_embeddings_batch(
|
|
219
|
+
conn: Connection,
|
|
220
|
+
content_hashes: list[str],
|
|
221
|
+
model_name: str,
|
|
222
|
+
) -> dict[str, list[float]]:
|
|
223
|
+
"""Retrieve multiple cached embeddings in one query.
|
|
224
|
+
|
|
225
|
+
More efficient than individual lookups for batches.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
conn: SQLite connection.
|
|
229
|
+
content_hashes: List of content hashes to lookup.
|
|
230
|
+
model_name: Embedding model identifier.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Dict mapping content_hash -> embedding for cache hits only.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
StorageError: If batch lookup fails.
|
|
237
|
+
"""
|
|
238
|
+
if not content_hashes:
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
cursor = conn.cursor()
|
|
243
|
+
|
|
244
|
+
# Build parameterized query
|
|
245
|
+
placeholders = ",".join("?" * len(content_hashes))
|
|
246
|
+
query = f"""
|
|
247
|
+
SELECT content_hash, embedding
|
|
248
|
+
FROM embedding_cache
|
|
249
|
+
WHERE content_hash IN ({placeholders})
|
|
250
|
+
AND model_name = ?
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
params = [*content_hashes, model_name]
|
|
254
|
+
cursor.execute(query, params)
|
|
255
|
+
|
|
256
|
+
# Parse results
|
|
257
|
+
results = {}
|
|
258
|
+
for content_hash, embedding_json in cursor.fetchall():
|
|
259
|
+
embedding = json.loads(embedding_json)
|
|
260
|
+
results[content_hash] = embedding
|
|
261
|
+
|
|
262
|
+
# Increment hit counts for all hits
|
|
263
|
+
if results:
|
|
264
|
+
hit_hashes = list(results.keys())
|
|
265
|
+
hit_placeholders = ",".join("?" * len(hit_hashes))
|
|
266
|
+
cursor.execute(
|
|
267
|
+
f"""
|
|
268
|
+
UPDATE embedding_cache
|
|
269
|
+
SET hit_count = hit_count + 1
|
|
270
|
+
WHERE content_hash IN ({hit_placeholders})
|
|
271
|
+
""",
|
|
272
|
+
hit_hashes,
|
|
273
|
+
)
|
|
274
|
+
conn.commit()
|
|
275
|
+
|
|
276
|
+
logger.debug(
|
|
277
|
+
"Batch cache lookup",
|
|
278
|
+
requested=len(content_hashes),
|
|
279
|
+
hits=len(results),
|
|
280
|
+
hit_rate=len(results) / len(content_hashes) if content_hashes else 0,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return results
|
|
284
|
+
|
|
285
|
+
except (sqlite3.Error, json.JSONDecodeError) as e:
|
|
286
|
+
msg = f"Failed to retrieve cached embeddings batch: {e}"
|
|
287
|
+
raise StorageError(
|
|
288
|
+
code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
|
|
289
|
+
message=msg,
|
|
290
|
+
context=storage_context(
|
|
291
|
+
table="embedding_cache",
|
|
292
|
+
query="get_cached_embeddings_batch",
|
|
293
|
+
),
|
|
294
|
+
) from e
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def put_cached_embeddings_batch(
|
|
298
|
+
conn: Connection,
|
|
299
|
+
embeddings: list[tuple[str, list[float]]],
|
|
300
|
+
model_name: str,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Store multiple embeddings in cache efficiently.
|
|
303
|
+
|
|
304
|
+
Uses executemany for batch insertion.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
conn: SQLite connection.
|
|
308
|
+
embeddings: List of (content_hash, embedding) tuples.
|
|
309
|
+
model_name: Embedding model identifier.
|
|
310
|
+
|
|
311
|
+
Raises:
|
|
312
|
+
StorageError: If batch cache write fails.
|
|
313
|
+
"""
|
|
314
|
+
if not embeddings:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
import time
|
|
319
|
+
|
|
320
|
+
cursor = conn.cursor()
|
|
321
|
+
now_ms = int(time.time() * 1000)
|
|
322
|
+
|
|
323
|
+
# Prepare batch data
|
|
324
|
+
batch_data = [
|
|
325
|
+
(
|
|
326
|
+
content_hash,
|
|
327
|
+
json.dumps(embedding),
|
|
328
|
+
model_name,
|
|
329
|
+
now_ms,
|
|
330
|
+
)
|
|
331
|
+
for content_hash, embedding in embeddings
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# Batch insert
|
|
335
|
+
cursor.executemany(
|
|
336
|
+
"""
|
|
337
|
+
INSERT OR REPLACE INTO embedding_cache
|
|
338
|
+
(content_hash, embedding, model_name, created_at, hit_count)
|
|
339
|
+
VALUES (?, ?, ?, ?, 0)
|
|
340
|
+
""",
|
|
341
|
+
batch_data,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
conn.commit()
|
|
345
|
+
|
|
346
|
+
logger.info(
|
|
347
|
+
"Batch cache write",
|
|
348
|
+
count=len(embeddings),
|
|
349
|
+
model=model_name,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
except (sqlite3.Error, TypeError) as e:
|
|
353
|
+
msg = f"Failed to cache embeddings batch: {e}"
|
|
354
|
+
raise StorageError(
|
|
355
|
+
code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
|
|
356
|
+
message=msg,
|
|
357
|
+
context=storage_context(
|
|
358
|
+
table="embedding_cache",
|
|
359
|
+
query="put_cached_embeddings_batch",
|
|
360
|
+
),
|
|
361
|
+
) from e
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_cache_stats(conn: Connection) -> dict[str, int]:
|
|
365
|
+
"""Get cache statistics for monitoring.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Dict with cache size, total hits, and model breakdown.
|
|
369
|
+
|
|
370
|
+
Example:
|
|
371
|
+
>>> stats = get_cache_stats(conn)
|
|
372
|
+
>>> stats
|
|
373
|
+
{
|
|
374
|
+
'total_entries': 10000,
|
|
375
|
+
'total_hits': 45000,
|
|
376
|
+
'models': {'jinaai/jina-embeddings-v2-base-code': 10000}
|
|
377
|
+
}
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
cursor = conn.cursor()
|
|
381
|
+
|
|
382
|
+
# Total entries
|
|
383
|
+
cursor.execute("SELECT COUNT(*) FROM embedding_cache")
|
|
384
|
+
total_entries = cursor.fetchone()[0]
|
|
385
|
+
|
|
386
|
+
# Total hits
|
|
387
|
+
cursor.execute("SELECT COALESCE(SUM(hit_count), 0) FROM embedding_cache")
|
|
388
|
+
total_hits = cursor.fetchone()[0]
|
|
389
|
+
|
|
390
|
+
# Model breakdown
|
|
391
|
+
cursor.execute(
|
|
392
|
+
"""
|
|
393
|
+
SELECT model_name, COUNT(*)
|
|
394
|
+
FROM embedding_cache
|
|
395
|
+
GROUP BY model_name
|
|
396
|
+
"""
|
|
397
|
+
)
|
|
398
|
+
models = dict(cursor.fetchall())
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
"total_entries": total_entries,
|
|
402
|
+
"total_hits": total_hits,
|
|
403
|
+
"models": models,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
except sqlite3.Error as e:
|
|
407
|
+
logger.warning("Failed to get cache stats", error=str(e))
|
|
408
|
+
return {"total_entries": 0, "total_hits": 0, "models": {}}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def clear_cache(conn: Connection, model_name: str | None = None) -> int:
|
|
412
|
+
"""Clear embedding cache.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
conn: SQLite connection.
|
|
416
|
+
model_name: Clear only entries for this model, or None for all.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Number of entries deleted.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
StorageError: If cache clear fails.
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
cursor = conn.cursor()
|
|
426
|
+
|
|
427
|
+
if model_name:
|
|
428
|
+
cursor.execute(
|
|
429
|
+
"DELETE FROM embedding_cache WHERE model_name = ?",
|
|
430
|
+
(model_name,),
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
cursor.execute("DELETE FROM embedding_cache")
|
|
434
|
+
|
|
435
|
+
deleted = cursor.rowcount
|
|
436
|
+
conn.commit()
|
|
437
|
+
|
|
438
|
+
logger.info(
|
|
439
|
+
"Cache cleared",
|
|
440
|
+
deleted=deleted,
|
|
441
|
+
model=model_name or "all",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return deleted
|
|
445
|
+
|
|
446
|
+
except sqlite3.Error as e:
|
|
447
|
+
msg = f"Failed to clear cache: {e}"
|
|
448
|
+
raise StorageError(
|
|
449
|
+
code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
|
|
450
|
+
message=msg,
|
|
451
|
+
context=storage_context(
|
|
452
|
+
table="embedding_cache",
|
|
453
|
+
query="clear_cache",
|
|
454
|
+
),
|
|
455
|
+
) from e
|