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,414 @@
|
|
|
1
|
+
"""Production embedder using fastembed with jina-embeddings-v2-base-code.
|
|
2
|
+
|
|
3
|
+
This module provides a thread-safe, cached embedder singleton for code chunks.
|
|
4
|
+
Uses ONNX int8 quantization for efficient CPU inference.
|
|
5
|
+
|
|
6
|
+
Key features:
|
|
7
|
+
- Model: jina-embeddings-v2-base-code (768-dim)
|
|
8
|
+
- Batching: Up to 64 documents per batch
|
|
9
|
+
- Caching: Content-hash based deduplication
|
|
10
|
+
- Thread-safe: Singleton with lock protection
|
|
11
|
+
- Fast: ~18 docs/sec on modern CPU
|
|
12
|
+
|
|
13
|
+
Performance characteristics (from TECHNICAL_SPEC):
|
|
14
|
+
- Model size: ~165MB on disk
|
|
15
|
+
- Context window: 8,192 tokens
|
|
16
|
+
- Batch size: 64 @ 512 tokens
|
|
17
|
+
- Throughput: ~18 docs/sec (int8, 128 seq len)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import threading
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from fastembed import TextEmbedding
|
|
27
|
+
|
|
28
|
+
from contextual.core.errors import ErrorCode, IndexingError, indexing_context
|
|
29
|
+
from contextual.observability.logging import get_logger
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
# ============================================================================
|
|
38
|
+
# CONSTANTS (from TECHNICAL_SPEC §4)
|
|
39
|
+
# ============================================================================
|
|
40
|
+
|
|
41
|
+
MODEL_NAME = "jinaai/jina-embeddings-v2-base-code"
|
|
42
|
+
"""jina-embeddings-v2-base-code - optimized for code understanding."""
|
|
43
|
+
|
|
44
|
+
DOCS_MODEL_NAME = "nomic-ai/nomic-embed-text-v1.5"
|
|
45
|
+
"""nomic-embed-text-v1.5 - optimized for natural language text (docs)."""
|
|
46
|
+
|
|
47
|
+
EMBEDDING_DIM = 768
|
|
48
|
+
"""Output dimensionality."""
|
|
49
|
+
|
|
50
|
+
BATCH_SIZE = 64
|
|
51
|
+
"""Maximum documents per batch for optimal throughput."""
|
|
52
|
+
|
|
53
|
+
MAX_CONTEXT_LENGTH = 8192
|
|
54
|
+
"""Maximum token context window."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ============================================================================
|
|
58
|
+
# EMBEDDER CLASS
|
|
59
|
+
# ============================================================================
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CodeEmbedder:
|
|
63
|
+
"""Thread-safe code embedder using jina-v2-base-code.
|
|
64
|
+
|
|
65
|
+
Wraps fastembed's TextEmbedding with:
|
|
66
|
+
- Singleton pattern for model reuse
|
|
67
|
+
- Batched inference for efficiency
|
|
68
|
+
- Content-hash caching integration
|
|
69
|
+
- Thread-safe operations
|
|
70
|
+
|
|
71
|
+
The model is loaded once and reused across all embedding operations.
|
|
72
|
+
Model weights are cached by fastembed in ~/.cache/fastembed.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
model: fastembed TextEmbedding instance
|
|
76
|
+
_lock: Threading lock for model access
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
"""Initialize embedder and load model.
|
|
81
|
+
|
|
82
|
+
Downloads model on first use (~165MB).
|
|
83
|
+
Subsequent runs load from cache.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
IndexingError: If model loading fails.
|
|
87
|
+
"""
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
logger.info(
|
|
91
|
+
"Initializing code embedder",
|
|
92
|
+
model=MODEL_NAME,
|
|
93
|
+
dimensions=EMBEDDING_DIM,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Initialize fastembed TextEmbedding
|
|
98
|
+
# This downloads the model on first use
|
|
99
|
+
self.model = TextEmbedding(
|
|
100
|
+
model_name=MODEL_NAME,
|
|
101
|
+
max_length=MAX_CONTEXT_LENGTH,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
logger.info(
|
|
105
|
+
"Code embedder initialized successfully",
|
|
106
|
+
model=MODEL_NAME,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
msg = f"Failed to initialize embedder: {e}"
|
|
111
|
+
raise IndexingError(
|
|
112
|
+
code=ErrorCode.INDEXING_EMBEDDING_MODEL_LOAD_FAILED,
|
|
113
|
+
message=msg,
|
|
114
|
+
context=indexing_context(),
|
|
115
|
+
) from e
|
|
116
|
+
|
|
117
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
118
|
+
"""Generate embeddings for a batch of texts.
|
|
119
|
+
|
|
120
|
+
This is the main entry point for embedding generation.
|
|
121
|
+
Handles batching automatically if input exceeds BATCH_SIZE.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
texts: List of text strings to embed (code chunks, queries, etc.)
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of 768-dimensional embedding vectors.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
IndexingError: If embedding generation fails.
|
|
131
|
+
|
|
132
|
+
Example:
|
|
133
|
+
>>> embedder = get_embedder()
|
|
134
|
+
>>> chunks = ["def parse():", "class Config:", "import json"]
|
|
135
|
+
>>> embeddings = embedder.embed(chunks)
|
|
136
|
+
>>> len(embeddings), len(embeddings[0])
|
|
137
|
+
(3, 768)
|
|
138
|
+
"""
|
|
139
|
+
if not texts:
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
# Process in batches if necessary
|
|
144
|
+
all_embeddings = []
|
|
145
|
+
|
|
146
|
+
for i in range(0, len(texts), BATCH_SIZE):
|
|
147
|
+
batch = texts[i : i + BATCH_SIZE]
|
|
148
|
+
|
|
149
|
+
logger.debug(
|
|
150
|
+
"Embedding batch",
|
|
151
|
+
batch_size=len(batch),
|
|
152
|
+
batch_index=i // BATCH_SIZE,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# fastembed returns a generator - convert to list
|
|
156
|
+
with self._lock:
|
|
157
|
+
batch_embeddings = list(self.model.embed(batch))
|
|
158
|
+
|
|
159
|
+
# Validate dimensions
|
|
160
|
+
for idx, emb in enumerate(batch_embeddings):
|
|
161
|
+
if len(emb) != EMBEDDING_DIM:
|
|
162
|
+
msg = (
|
|
163
|
+
f"Unexpected embedding dimension: "
|
|
164
|
+
f"expected {EMBEDDING_DIM}, got {len(emb)}"
|
|
165
|
+
)
|
|
166
|
+
raise IndexingError(
|
|
167
|
+
code=ErrorCode.INDEXING_EMBEDDING_INFERENCE_FAILED,
|
|
168
|
+
message=msg,
|
|
169
|
+
context=indexing_context(),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
all_embeddings.extend(batch_embeddings)
|
|
173
|
+
|
|
174
|
+
logger.info(
|
|
175
|
+
"Embeddings generated",
|
|
176
|
+
count=len(texts),
|
|
177
|
+
batches=len(range(0, len(texts), BATCH_SIZE)),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return [list(emb) for emb in all_embeddings]
|
|
181
|
+
|
|
182
|
+
except IndexingError:
|
|
183
|
+
# Re-raise our own errors
|
|
184
|
+
raise
|
|
185
|
+
except Exception as e:
|
|
186
|
+
msg = f"Embedding inference failed: {e}"
|
|
187
|
+
raise IndexingError(
|
|
188
|
+
code=ErrorCode.INDEXING_EMBEDDING_INFERENCE_FAILED,
|
|
189
|
+
message=msg,
|
|
190
|
+
context=indexing_context(),
|
|
191
|
+
) from e
|
|
192
|
+
|
|
193
|
+
def embed_query(self, query: str) -> list[float]:
|
|
194
|
+
"""Embed a single query string.
|
|
195
|
+
|
|
196
|
+
Convenience method for embedding search queries.
|
|
197
|
+
jina-v2-code is symmetric, so no special query prefix needed.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
query: Query string to embed.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
768-dimensional embedding vector.
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
>>> embedder = get_embedder()
|
|
207
|
+
>>> query_vec = embedder.embed_query("parse JSON configuration")
|
|
208
|
+
>>> len(query_vec)
|
|
209
|
+
768
|
|
210
|
+
"""
|
|
211
|
+
return self.embed([query])[0]
|
|
212
|
+
|
|
213
|
+
def compute_content_hash(self, text: str) -> str:
|
|
214
|
+
"""Compute SHA-256 hash of text for caching.
|
|
215
|
+
|
|
216
|
+
Used to deduplicate embeddings - identical content gets same hash.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
text: Text content to hash.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Hex-encoded SHA-256 hash (64 chars).
|
|
223
|
+
"""
|
|
224
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class DocsEmbedder:
|
|
228
|
+
"""Thread-safe docs embedder using nomic-embed-text-v1.5.
|
|
229
|
+
|
|
230
|
+
Identical to CodeEmbedder in behavior (batching, locking, dimension checks).
|
|
231
|
+
Only difference is the underlying model name.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self) -> None:
|
|
235
|
+
self._lock = threading.Lock()
|
|
236
|
+
|
|
237
|
+
logger.info(
|
|
238
|
+
"Initializing docs embedder",
|
|
239
|
+
model=DOCS_MODEL_NAME,
|
|
240
|
+
dimensions=EMBEDDING_DIM,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
self.model = TextEmbedding(
|
|
245
|
+
model_name=DOCS_MODEL_NAME,
|
|
246
|
+
max_length=MAX_CONTEXT_LENGTH,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
logger.info(
|
|
250
|
+
"Docs embedder initialized successfully",
|
|
251
|
+
model=DOCS_MODEL_NAME,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
msg = f"Failed to initialize embedder: {e}"
|
|
256
|
+
raise IndexingError(
|
|
257
|
+
code=ErrorCode.INDEXING_EMBEDDING_MODEL_LOAD_FAILED,
|
|
258
|
+
message=msg,
|
|
259
|
+
context=indexing_context(),
|
|
260
|
+
) from e
|
|
261
|
+
|
|
262
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
263
|
+
if not texts:
|
|
264
|
+
return []
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
all_embeddings = []
|
|
268
|
+
|
|
269
|
+
for i in range(0, len(texts), BATCH_SIZE):
|
|
270
|
+
batch = texts[i : i + BATCH_SIZE]
|
|
271
|
+
|
|
272
|
+
logger.debug(
|
|
273
|
+
"Embedding batch",
|
|
274
|
+
batch_size=len(batch),
|
|
275
|
+
batch_index=i // BATCH_SIZE,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
with self._lock:
|
|
279
|
+
batch_embeddings = list(self.model.embed(batch))
|
|
280
|
+
|
|
281
|
+
for emb in batch_embeddings:
|
|
282
|
+
if len(emb) != EMBEDDING_DIM:
|
|
283
|
+
msg = (
|
|
284
|
+
f"Unexpected embedding dimension: "
|
|
285
|
+
f"expected {EMBEDDING_DIM}, got {len(emb)}"
|
|
286
|
+
)
|
|
287
|
+
raise IndexingError(
|
|
288
|
+
code=ErrorCode.INDEXING_EMBEDDING_INFERENCE_FAILED,
|
|
289
|
+
message=msg,
|
|
290
|
+
context=indexing_context(),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
all_embeddings.extend(batch_embeddings)
|
|
294
|
+
|
|
295
|
+
logger.info(
|
|
296
|
+
"Embeddings generated",
|
|
297
|
+
count=len(texts),
|
|
298
|
+
batches=len(range(0, len(texts), BATCH_SIZE)),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return [list(emb) for emb in all_embeddings]
|
|
302
|
+
|
|
303
|
+
except IndexingError:
|
|
304
|
+
raise
|
|
305
|
+
except Exception as e:
|
|
306
|
+
msg = f"Embedding inference failed: {e}"
|
|
307
|
+
raise IndexingError(
|
|
308
|
+
code=ErrorCode.INDEXING_EMBEDDING_INFERENCE_FAILED,
|
|
309
|
+
message=msg,
|
|
310
|
+
context=indexing_context(),
|
|
311
|
+
) from e
|
|
312
|
+
|
|
313
|
+
def embed_query(self, query: str) -> list[float]:
|
|
314
|
+
return self.embed([query])[0]
|
|
315
|
+
|
|
316
|
+
def compute_content_hash(self, text: str) -> str:
|
|
317
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ============================================================================
|
|
321
|
+
# SINGLETON PATTERN
|
|
322
|
+
# ============================================================================
|
|
323
|
+
|
|
324
|
+
_embedder: CodeEmbedder | None = None
|
|
325
|
+
_embedder_lock = threading.Lock()
|
|
326
|
+
|
|
327
|
+
_docs_embedder: DocsEmbedder | None = None
|
|
328
|
+
_docs_embedder_lock = threading.Lock()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_embedder() -> CodeEmbedder:
|
|
332
|
+
"""Get or create the global embedder singleton.
|
|
333
|
+
|
|
334
|
+
Thread-safe lazy initialization. The embedder is created once
|
|
335
|
+
on first call and reused for all subsequent calls.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Shared CodeEmbedder instance.
|
|
339
|
+
|
|
340
|
+
Example:
|
|
341
|
+
>>> embedder1 = get_embedder()
|
|
342
|
+
>>> embedder2 = get_embedder()
|
|
343
|
+
>>> embedder1 is embedder2
|
|
344
|
+
True
|
|
345
|
+
"""
|
|
346
|
+
global _embedder
|
|
347
|
+
|
|
348
|
+
if _embedder is None:
|
|
349
|
+
with _embedder_lock:
|
|
350
|
+
# Double-check pattern
|
|
351
|
+
if _embedder is None:
|
|
352
|
+
_embedder = CodeEmbedder()
|
|
353
|
+
|
|
354
|
+
return _embedder
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_docs_embedder() -> DocsEmbedder:
|
|
358
|
+
"""Get or create the global docs embedder singleton."""
|
|
359
|
+
global _docs_embedder
|
|
360
|
+
|
|
361
|
+
if _docs_embedder is None:
|
|
362
|
+
with _docs_embedder_lock:
|
|
363
|
+
if _docs_embedder is None:
|
|
364
|
+
_docs_embedder = DocsEmbedder()
|
|
365
|
+
|
|
366
|
+
return _docs_embedder
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def reset_embedder() -> None:
|
|
370
|
+
"""Reset the global embedder singleton.
|
|
371
|
+
|
|
372
|
+
Used for testing or to force model reload.
|
|
373
|
+
Should NOT be called in production code.
|
|
374
|
+
"""
|
|
375
|
+
global _embedder
|
|
376
|
+
with _embedder_lock:
|
|
377
|
+
_embedder = None
|
|
378
|
+
logger.warning("Embedder singleton reset")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ============================================================================
|
|
382
|
+
# BATCH EMBEDDING HELPERS
|
|
383
|
+
# ============================================================================
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def embed_with_hashes(
|
|
387
|
+
texts: list[str],
|
|
388
|
+
) -> list[tuple[str, list[float]]]:
|
|
389
|
+
"""Embed texts and return (content_hash, embedding) pairs.
|
|
390
|
+
|
|
391
|
+
Useful for populating embedding cache or detecting duplicates.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
texts: Texts to embed.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
List of (content_hash, embedding) tuples.
|
|
398
|
+
|
|
399
|
+
Example:
|
|
400
|
+
>>> embedder = get_embedder()
|
|
401
|
+
>>> results = embed_with_hashes(["def foo():", "class Bar:"])
|
|
402
|
+
>>> hash1, emb1 = results[0]
|
|
403
|
+
>>> len(hash1), len(emb1)
|
|
404
|
+
(64, 768)
|
|
405
|
+
"""
|
|
406
|
+
embedder = get_embedder()
|
|
407
|
+
|
|
408
|
+
# Generate embeddings
|
|
409
|
+
embeddings = embedder.embed(texts)
|
|
410
|
+
|
|
411
|
+
# Compute hashes
|
|
412
|
+
hashes = [embedder.compute_content_hash(text) for text in texts]
|
|
413
|
+
|
|
414
|
+
return list(zip(hashes, embeddings, strict=False))
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""High-level embedding API with caching integration.
|
|
2
|
+
|
|
3
|
+
Provides convenience functions that combine embedder + cache for common workflows:
|
|
4
|
+
- embed_chunks_with_cache: Batch embed with cache lookup/write
|
|
5
|
+
- embed_query: Simple query embedding (no cache)
|
|
6
|
+
- warm_cache: Pre-populate cache from database
|
|
7
|
+
|
|
8
|
+
These are the recommended entry points for indexing and search operations.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from contextual.embedding.cache import (
|
|
16
|
+
get_cached_embeddings_batch,
|
|
17
|
+
put_cached_embeddings_batch,
|
|
18
|
+
)
|
|
19
|
+
from contextual.embedding.embedder import MODEL_NAME, get_embedder
|
|
20
|
+
from contextual.observability.logging import get_logger
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from sqlite3 import Connection
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ============================================================================
|
|
30
|
+
# CACHED EMBEDDING WORKFLOW
|
|
31
|
+
# ============================================================================
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def embed_chunks(
|
|
35
|
+
conn: Connection,
|
|
36
|
+
chunks: list[tuple[str, str]],
|
|
37
|
+
) -> list[tuple[str, list[float]]]:
|
|
38
|
+
"""Embed chunks with cache lookup and write.
|
|
39
|
+
|
|
40
|
+
This is the main entry point for indexing pipelines.
|
|
41
|
+
|
|
42
|
+
Workflow:
|
|
43
|
+
1. Extract content hashes from chunks
|
|
44
|
+
2. Lookup cached embeddings in batch
|
|
45
|
+
3. Generate embeddings only for cache misses
|
|
46
|
+
4. Write new embeddings to cache
|
|
47
|
+
5. Return all embeddings in original order
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
conn: SQLite connection (for cache operations).
|
|
51
|
+
chunks: List of (content_hash, text) tuples.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
List of (content_hash, embedding) tuples in original order.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
>>> chunks = [
|
|
58
|
+
... ("abc123", "def parse_json():"),
|
|
59
|
+
... ("def456", "class Config:"),
|
|
60
|
+
... ]
|
|
61
|
+
>>> results = embed_chunks(conn, chunks)
|
|
62
|
+
>>> len(results)
|
|
63
|
+
2
|
|
64
|
+
>>> hash1, emb1 = results[0]
|
|
65
|
+
>>> len(emb1)
|
|
66
|
+
768
|
|
67
|
+
"""
|
|
68
|
+
if not chunks:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
embedder = get_embedder()
|
|
72
|
+
|
|
73
|
+
# Extract hashes and texts
|
|
74
|
+
hashes = [h for h, _ in chunks]
|
|
75
|
+
texts = [t for _, t in chunks]
|
|
76
|
+
|
|
77
|
+
# Batch cache lookup
|
|
78
|
+
cached = get_cached_embeddings_batch(conn, hashes, MODEL_NAME)
|
|
79
|
+
|
|
80
|
+
# Identify cache misses
|
|
81
|
+
miss_indices = [i for i, h in enumerate(hashes) if h not in cached]
|
|
82
|
+
miss_texts = [texts[i] for i in miss_indices]
|
|
83
|
+
miss_hashes = [hashes[i] for i in miss_indices]
|
|
84
|
+
|
|
85
|
+
logger.info(
|
|
86
|
+
"Embedding with cache",
|
|
87
|
+
total=len(chunks),
|
|
88
|
+
cached=len(cached),
|
|
89
|
+
misses=len(miss_indices),
|
|
90
|
+
hit_rate=len(cached) / len(chunks) if chunks else 0,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Generate embeddings for misses
|
|
94
|
+
new_embeddings = {}
|
|
95
|
+
if miss_texts:
|
|
96
|
+
logger.debug("Generating embeddings for cache misses", count=len(miss_texts))
|
|
97
|
+
|
|
98
|
+
fresh_embeddings = embedder.embed(miss_texts)
|
|
99
|
+
|
|
100
|
+
# Build miss hash -> embedding map
|
|
101
|
+
for hash_val, embedding in zip(miss_hashes, fresh_embeddings, strict=False):
|
|
102
|
+
new_embeddings[hash_val] = embedding
|
|
103
|
+
|
|
104
|
+
# Write to cache
|
|
105
|
+
cache_entries = list(zip(miss_hashes, fresh_embeddings, strict=False))
|
|
106
|
+
put_cached_embeddings_batch(conn, cache_entries, MODEL_NAME)
|
|
107
|
+
|
|
108
|
+
# Combine cached + new in original order
|
|
109
|
+
results = []
|
|
110
|
+
for hash_val in hashes:
|
|
111
|
+
if hash_val in cached:
|
|
112
|
+
embedding = cached[hash_val]
|
|
113
|
+
else:
|
|
114
|
+
embedding = new_embeddings[hash_val]
|
|
115
|
+
|
|
116
|
+
results.append((hash_val, embedding))
|
|
117
|
+
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def embed_query_simple(query: str) -> list[float]:
|
|
122
|
+
"""Embed a search query (no caching).
|
|
123
|
+
|
|
124
|
+
Queries are not cached because:
|
|
125
|
+
1. They are unique and rarely repeated
|
|
126
|
+
2. Cache lookup overhead > embedding time for single item
|
|
127
|
+
3. Query embedding is already fast (~55ms)
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
query: Query string.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
768-dim embedding vector.
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
>>> embedding = embed_query_simple("parse JSON configuration")
|
|
137
|
+
>>> len(embedding)
|
|
138
|
+
768
|
|
139
|
+
"""
|
|
140
|
+
embedder = get_embedder()
|
|
141
|
+
return embedder.embed_query(query)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ============================================================================
|
|
145
|
+
# CACHE WARMING
|
|
146
|
+
# ============================================================================
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def warm_cache_from_chunks(
|
|
150
|
+
conn: Connection,
|
|
151
|
+
chunk_ids: list[int] | None = None,
|
|
152
|
+
) -> int:
|
|
153
|
+
"""Pre-populate embedding cache from existing chunks.
|
|
154
|
+
|
|
155
|
+
Useful after:
|
|
156
|
+
- Fresh database creation
|
|
157
|
+
- Cache cleared
|
|
158
|
+
- Model upgrade
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
conn: SQLite connection.
|
|
162
|
+
chunk_ids: Specific chunk IDs to warm, or None for all.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Number of embeddings cached.
|
|
166
|
+
|
|
167
|
+
Example:
|
|
168
|
+
>>> count = warm_cache_from_chunks(conn)
|
|
169
|
+
>>> print(f"Cached {count} embeddings")
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
cursor = conn.cursor()
|
|
173
|
+
|
|
174
|
+
# Fetch chunks
|
|
175
|
+
if chunk_ids:
|
|
176
|
+
placeholders = ",".join("?" * len(chunk_ids))
|
|
177
|
+
query = f"""
|
|
178
|
+
SELECT content_hash, content
|
|
179
|
+
FROM chunks
|
|
180
|
+
WHERE id IN ({placeholders})
|
|
181
|
+
AND content_hash IS NOT NULL
|
|
182
|
+
"""
|
|
183
|
+
cursor.execute(query, chunk_ids)
|
|
184
|
+
else:
|
|
185
|
+
query = """
|
|
186
|
+
SELECT content_hash, content
|
|
187
|
+
FROM chunks
|
|
188
|
+
WHERE content_hash IS NOT NULL
|
|
189
|
+
"""
|
|
190
|
+
cursor.execute(query)
|
|
191
|
+
|
|
192
|
+
rows = cursor.fetchall()
|
|
193
|
+
chunks = [(h, c) for h, c in rows]
|
|
194
|
+
|
|
195
|
+
logger.info("Warming cache from chunks", chunk_count=len(chunks))
|
|
196
|
+
|
|
197
|
+
# Use cached embedding workflow
|
|
198
|
+
# (will skip already-cached entries automatically)
|
|
199
|
+
results = embed_chunks(conn, chunks)
|
|
200
|
+
|
|
201
|
+
return len(results)
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error("Cache warming failed", error=str(e))
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ============================================================================
|
|
209
|
+
# STATISTICS
|
|
210
|
+
# ============================================================================
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def get_embedding_stats(conn: Connection) -> dict[str, int | float]:
|
|
214
|
+
"""Get embedding and cache statistics.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Dict with cache stats and efficiency metrics.
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> stats = get_embedding_stats(conn)
|
|
221
|
+
>>> stats
|
|
222
|
+
{
|
|
223
|
+
'cache_entries': 10000,
|
|
224
|
+
'cache_hits': 45000,
|
|
225
|
+
'cache_hit_rate': 0.82,
|
|
226
|
+
'estimated_time_saved_hours': 12.5
|
|
227
|
+
}
|
|
228
|
+
"""
|
|
229
|
+
from contextual.embedding.cache import get_cache_stats
|
|
230
|
+
|
|
231
|
+
cache_stats = get_cache_stats(conn)
|
|
232
|
+
|
|
233
|
+
# Calculate efficiency metrics
|
|
234
|
+
total_entries = cache_stats["total_entries"]
|
|
235
|
+
total_hits = cache_stats["total_hits"]
|
|
236
|
+
|
|
237
|
+
# Assume ~55ms per embedding generation
|
|
238
|
+
# Cache hit saves ~55ms, lookup costs ~0.1ms
|
|
239
|
+
time_saved_ms = total_hits * 55
|
|
240
|
+
time_saved_hours = time_saved_ms / 1000 / 60 / 60
|
|
241
|
+
|
|
242
|
+
cache_hit_rate = 0.0
|
|
243
|
+
if total_entries + total_hits > 0:
|
|
244
|
+
cache_hit_rate = total_hits / (total_entries + total_hits)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"cache_entries": total_entries,
|
|
248
|
+
"cache_hits": total_hits,
|
|
249
|
+
"cache_hit_rate": round(cache_hit_rate, 3),
|
|
250
|
+
"estimated_time_saved_hours": round(time_saved_hours, 2),
|
|
251
|
+
"models": cache_stats["models"],
|
|
252
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Git integration module for temporal code attribution.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- GitRepository: Wrapper for git operations
|
|
5
|
+
- BlameInfo: Line range attribution
|
|
6
|
+
- ChunkAttribution: Aggregated chunk metadata
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextual.git.blame import (
|
|
12
|
+
BlameInfo,
|
|
13
|
+
ChunkAttribution,
|
|
14
|
+
GitRepository,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BlameInfo",
|
|
20
|
+
"ChunkAttribution",
|
|
21
|
+
"GitRepository",
|
|
22
|
+
]
|