vectr 1.0.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.
- agent/__init__.py +0 -0
- agent/cartographer.py +428 -0
- agent/chunk_quality.py +1119 -0
- agent/config.py +648 -0
- agent/config.yaml +1153 -0
- agent/eviction_advisor.py +447 -0
- agent/identifier_hint.py +68 -0
- agent/indexer/__init__.py +112 -0
- agent/indexer/_chunking.py +458 -0
- agent/indexer/_constants.py +105 -0
- agent/indexer/_core.py +878 -0
- agent/indexer/_types.py +157 -0
- agent/instance_registry.py +127 -0
- agent/llm_client.py +8 -0
- agent/model_cache.py +101 -0
- agent/prompt_templates.py +31 -0
- agent/searcher.py +848 -0
- agent/strategy_selector.py +299 -0
- agent/symbol_graph/__init__.py +131 -0
- agent/symbol_graph/_constants.py +366 -0
- agent/symbol_graph/_extraction.py +528 -0
- agent/symbol_graph/_graph.py +1878 -0
- agent/symbol_graph/_types.py +35 -0
- agent/templates/claude_md.md +65 -0
- agent/templates/claude_md_search_only.md +27 -0
- agent/templates/cursor_mcp.json.template +7 -0
- agent/templates/cursor_rules_header.txt +5 -0
- agent/templates/hook_no_double_recall.txt +1 -0
- agent/templates/mcp.json.template +8 -0
- agent/templates/session_start_guidance_default.txt +3 -0
- agent/templates/session_start_guidance_hooks_aware.txt +1 -0
- agent/templates/tool_loading_guidance_claude.txt +1 -0
- agent/templates/tool_loading_guidance_claude_search_only.txt +1 -0
- agent/templates/vscode_mcp.json.template +8 -0
- agent/tool_necessity_probe.py +177 -0
- agent/version_stamp.py +58 -0
- agent/watcher.py +515 -0
- agent/working_context_store/__init__.py +76 -0
- agent/working_context_store/_audit.py +46 -0
- agent/working_context_store/_encryption.py +75 -0
- agent/working_context_store/_store.py +1130 -0
- agent/working_context_store/_types.py +50 -0
- api.py +101 -0
- app/__init__.py +0 -0
- app/models.py +368 -0
- app/routes.py +451 -0
- app/service.py +1055 -0
- integrations/__init__.py +0 -0
- integrations/mcp_server/__init__.py +73 -0
- integrations/mcp_server/_dispatch.py +782 -0
- integrations/mcp_server/_schemas.py +484 -0
- integrations/mcp_server/_session.py +86 -0
- integrations/vscode_bridge.py +71 -0
- integrations/workspace_detect.py +259 -0
- main.py +2037 -0
- vectr-1.0.0.dist-info/METADATA +37 -0
- vectr-1.0.0.dist-info/RECORD +61 -0
- vectr-1.0.0.dist-info/WHEEL +5 -0
- vectr-1.0.0.dist-info/entry_points.txt +2 -0
- vectr-1.0.0.dist-info/licenses/LICENSE +21 -0
- vectr-1.0.0.dist-info/top_level.txt +5 -0
agent/indexer/_core.py
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
"""CodeIndexer: ChromaDB-backed index orchestration (chunking + embed + upsert)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import chromadb
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from agent.chunk_quality import build_purpose_text
|
|
16
|
+
from agent.config import DUAL_VECTOR_ENABLED as _DUAL_VECTOR_ENABLED
|
|
17
|
+
from agent.config import EMBEDDING_DEFAULT_MODEL as _EMBEDDING_DEFAULT_MODEL
|
|
18
|
+
from agent.indexer._constants import (
|
|
19
|
+
EXCLUDED_DIRS,
|
|
20
|
+
_FILE_BATCH_SIZE,
|
|
21
|
+
_EMBED_BATCH_SIZE,
|
|
22
|
+
_UPSERT_BATCH_SIZE,
|
|
23
|
+
_CHUNK_WORKERS,
|
|
24
|
+
_MTIME_CACHE_SCHEMA_KEY,
|
|
25
|
+
INDEXING_SCHEMA_VERSION,
|
|
26
|
+
_EMBED_MODEL_STAMP_FILE,
|
|
27
|
+
)
|
|
28
|
+
from agent.indexer._chunking import chunk_file
|
|
29
|
+
from agent.indexer._types import CodeChunk
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CodeIndexer:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
workspace_root: str,
|
|
38
|
+
embed_model: str = _EMBEDDING_DEFAULT_MODEL,
|
|
39
|
+
db_path: str | None = None,
|
|
40
|
+
extra_roots: list[str] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.workspace_root = Path(workspace_root).resolve()
|
|
43
|
+
self._extra_roots: list[Path] = [Path(r).resolve() for r in (extra_roots or [])]
|
|
44
|
+
self.embed_model = embed_model
|
|
45
|
+
|
|
46
|
+
db_dir = Path(db_path) if db_path else Path.home() / ".cache" / "vectr" / "db" / self._workspace_hash()
|
|
47
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self._db_dir = db_dir
|
|
49
|
+
|
|
50
|
+
self._client = chromadb.PersistentClient(path=str(db_dir))
|
|
51
|
+
self._create_collections()
|
|
52
|
+
self._last_indexed: float = 0.0
|
|
53
|
+
self._indexed_files: set[str] = set()
|
|
54
|
+
self._lang_cache: tuple[int, dict[str, dict[str, int]]] | None = None # (chunk_count, per-lang stats) — UPG-3.1/3.3
|
|
55
|
+
|
|
56
|
+
# Deferred: look up get_embed_provider through the package namespace so that
|
|
57
|
+
# test-time monkeypatching of agent.indexer.get_embed_provider is honoured
|
|
58
|
+
# (identical to the original flat-module behaviour where the function lived
|
|
59
|
+
# in the same module namespace that patches target).
|
|
60
|
+
import agent.indexer as _idx
|
|
61
|
+
self._embed_provider = _idx.get_embed_provider(embed_model)
|
|
62
|
+
|
|
63
|
+
def _workspace_hash(self) -> str:
|
|
64
|
+
return hashlib.md5(str(self.workspace_root).encode()).hexdigest()[:12]
|
|
65
|
+
|
|
66
|
+
def _create_collections(self) -> None:
|
|
67
|
+
self._collection = self._client.get_or_create_collection(
|
|
68
|
+
name="code_chunks",
|
|
69
|
+
metadata={
|
|
70
|
+
"hnsw:space": "cosine",
|
|
71
|
+
"hnsw:construction_ef": 200, # default 100 — denser graph, better recall
|
|
72
|
+
"hnsw:search_ef": 100, # default 10 — wider beam search at query time
|
|
73
|
+
"hnsw:M": 32, # default 16 — more neighbours per node
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
# ARCH-4: second collection holding the body-stripped "purpose" vector
|
|
77
|
+
# (qualified signature + docstring) for symbol-bearing chunks only —
|
|
78
|
+
# keyed by the SAME chunk_id as `self._collection`. get_or_create_collection
|
|
79
|
+
# means an existing (pre-ARCH-4) workspace transparently gets an empty
|
|
80
|
+
# purpose collection rather than an error; queries against it then return
|
|
81
|
+
# no candidates until the workspace is (re)indexed, which is exactly the
|
|
82
|
+
# graceful body-only fallback the spec calls for.
|
|
83
|
+
self._purpose_collection = self._client.get_or_create_collection(
|
|
84
|
+
name="code_chunks_purpose",
|
|
85
|
+
metadata={
|
|
86
|
+
"hnsw:space": "cosine",
|
|
87
|
+
"hnsw:construction_ef": 200,
|
|
88
|
+
"hnsw:search_ef": 100,
|
|
89
|
+
"hnsw:M": 32,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _recreate_collections(self) -> None:
|
|
94
|
+
"""Drop and recreate both vector collections.
|
|
95
|
+
|
|
96
|
+
ChromaDB pins a collection's embedding dimensionality at first insert
|
|
97
|
+
and keeps it even after every entry is deleted, so an embedding-model
|
|
98
|
+
swap to a DIFFERENT-dimension model would crash the per-file
|
|
99
|
+
delete-then-reinsert rebuild (`force=True`) against the old
|
|
100
|
+
collection. On an embed-model stamp mismatch the collections
|
|
101
|
+
themselves must be dropped, not just their contents — this also
|
|
102
|
+
guarantees no old-model vector can survive regardless of any
|
|
103
|
+
per-file bookkeeping.
|
|
104
|
+
"""
|
|
105
|
+
for name in ("code_chunks", "code_chunks_purpose"):
|
|
106
|
+
try:
|
|
107
|
+
self._client.delete_collection(name)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass # collection may not exist yet — nothing to drop
|
|
110
|
+
self._create_collections()
|
|
111
|
+
self._lang_cache = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def all_roots(self) -> list[Path]:
|
|
115
|
+
"""All workspace roots: primary first, then extra roots in order."""
|
|
116
|
+
return [self.workspace_root] + self._extra_roots
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Public API
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def index_workspace(
|
|
123
|
+
self, gitignore_patterns: list[str] | None = None, force: bool = False,
|
|
124
|
+
) -> tuple[int, int]:
|
|
125
|
+
"""Walk workspace, index all supported files. Returns (files_indexed, chunks_total).
|
|
126
|
+
|
|
127
|
+
Three-phase pipeline:
|
|
128
|
+
1. Parallel chunking — ThreadPoolExecutor, tree-sitter releases GIL
|
|
129
|
+
2. Global batch embed — 256-chunk batches across all files (vs 64 per-file)
|
|
130
|
+
3. Incremental skip — files unchanged since last index are skipped via mtime cache
|
|
131
|
+
|
|
132
|
+
Before indexing, chunks for files no longer in the walk set (excluded via
|
|
133
|
+
.vectrignore/.gitignore, deleted, or moved out of all roots) are pruned so
|
|
134
|
+
the collection never carries orphaned chunks (UPG-8.4).
|
|
135
|
+
|
|
136
|
+
force=True ignores the mtime cache and re-chunks/re-embeds every file,
|
|
137
|
+
replacing its chunks — a clean rebuild that recovers from any cache/
|
|
138
|
+
collection desync without a manual cache wipe (UPG-8.6).
|
|
139
|
+
"""
|
|
140
|
+
from integrations.workspace_detect import (
|
|
141
|
+
should_index_file, get_gitignore_patterns, get_vectrignore_dirs,
|
|
142
|
+
get_vectrignore_file_globs, get_vectrignore_regexes,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Collect candidate files across all roots; each root gets its own
|
|
146
|
+
# gitignore/vectrignore patterns so per-project exclusions are respected.
|
|
147
|
+
all_files: list[Path] = []
|
|
148
|
+
for root in self.all_roots:
|
|
149
|
+
root_patterns = gitignore_patterns or get_gitignore_patterns(str(root))
|
|
150
|
+
# UPG-13.3: .vectrignore file-glob entries (e.g. "*.generated.py") are
|
|
151
|
+
# matched the same way as gitignore patterns — additive, on top of the
|
|
152
|
+
# existing bare directory-name exclusions below.
|
|
153
|
+
root_patterns = [*root_patterns, *get_vectrignore_file_globs(str(root))]
|
|
154
|
+
vectrignore_dirs = get_vectrignore_dirs(str(root))
|
|
155
|
+
# UPG-EXCLUDE-REGEX: `re:<pattern>` .vectrignore entries, matched
|
|
156
|
+
# against each file's workspace-relative path — additive, on top
|
|
157
|
+
# of the dir-name and glob exclusions above.
|
|
158
|
+
vectrignore_regexes = get_vectrignore_regexes(str(root))
|
|
159
|
+
all_excluded = EXCLUDED_DIRS | vectrignore_dirs
|
|
160
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
161
|
+
dirnames[:] = [d for d in dirnames if d not in all_excluded and not d.startswith(".")]
|
|
162
|
+
for fname in filenames:
|
|
163
|
+
fpath = Path(dirpath) / fname
|
|
164
|
+
if should_index_file(str(fpath), root_patterns, extra_excluded_dirs=vectrignore_dirs,
|
|
165
|
+
workspace_root=str(root),
|
|
166
|
+
extra_excluded_regexes=vectrignore_regexes):
|
|
167
|
+
all_files.append(fpath)
|
|
168
|
+
|
|
169
|
+
should_index_paths = {str(f) for f in all_files}
|
|
170
|
+
|
|
171
|
+
# Reconcile: drop chunks for files no longer indexable (excluded via
|
|
172
|
+
# .vectrignore/.gitignore, deleted, or moved out of all roots). Without
|
|
173
|
+
# this, editing .vectrignore stops *new* indexing but leaves the old
|
|
174
|
+
# chunks in the collection forever. (UPG-8.4)
|
|
175
|
+
mtime_cache = self._load_mtime_cache()
|
|
176
|
+
pruned = self._prune_orphaned_chunks(should_index_paths, mtime_cache)
|
|
177
|
+
|
|
178
|
+
# UPG-EMBEDDER-SWAP-GRANITE: vectors from two different embedding
|
|
179
|
+
# models must never silently coexist in one collection (a same-
|
|
180
|
+
# dimension model swap is exactly the silent-corruption case — cosine
|
|
181
|
+
# distances between the two spaces are meaningless even though
|
|
182
|
+
# ChromaDB's HNSW index accepts them without error). A stamp mismatch
|
|
183
|
+
# — including a MISSING stamp, treated as a mismatch rather than a
|
|
184
|
+
# match since a pre-existing index from an older vectr version
|
|
185
|
+
# predates this mechanism — drops and recreates both collections
|
|
186
|
+
# (ChromaDB pins dimensionality at first insert, so a different-
|
|
187
|
+
# dimension model would otherwise crash the reinsert) and forces the
|
|
188
|
+
# full-rebuild path: every file is re-chunked/re-embedded into the
|
|
189
|
+
# fresh collections, so no old-model vector can survive.
|
|
190
|
+
stored_embed_model = self._stored_embed_model()
|
|
191
|
+
if stored_embed_model != self.embed_model:
|
|
192
|
+
if not force:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Embedding model changed (index built with %r, now configured "
|
|
195
|
+
"with %r) — forcing a full vector index rebuild so vectors "
|
|
196
|
+
"from the two models never mix in one collection",
|
|
197
|
+
stored_embed_model, self.embed_model,
|
|
198
|
+
)
|
|
199
|
+
force = True
|
|
200
|
+
self._recreate_collections()
|
|
201
|
+
|
|
202
|
+
if force:
|
|
203
|
+
# Clean rebuild: ignore the mtime cache so every file is re-indexed,
|
|
204
|
+
# and (in Phase 2) its existing chunks are deleted first. (UPG-8.6)
|
|
205
|
+
mtime_cache = {}
|
|
206
|
+
|
|
207
|
+
# Incremental: split into files to index vs unchanged files to skip
|
|
208
|
+
to_index: list[tuple[Path, float]] = []
|
|
209
|
+
for f in all_files:
|
|
210
|
+
try:
|
|
211
|
+
mtime = f.stat().st_mtime
|
|
212
|
+
except OSError:
|
|
213
|
+
continue
|
|
214
|
+
cached = mtime_cache.get(str(f))
|
|
215
|
+
if cached is None or cached != mtime:
|
|
216
|
+
to_index.append((f, mtime))
|
|
217
|
+
else:
|
|
218
|
+
self._indexed_files.add(str(f)) # already indexed — track in memory
|
|
219
|
+
|
|
220
|
+
if not to_index:
|
|
221
|
+
if pruned:
|
|
222
|
+
self._save_mtime_cache(mtime_cache) # persist orphan removals
|
|
223
|
+
self._write_embed_model_stamp() # cheap no-op when already current
|
|
224
|
+
logger.info("All %d files up to date — nothing to re-index", len(all_files))
|
|
225
|
+
self._last_indexed = time.time()
|
|
226
|
+
return len(self._indexed_files), self._collection.count()
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
"Indexing %d/%d files (%d unchanged, skipped)...",
|
|
230
|
+
len(to_index), len(all_files), len(all_files) - len(to_index),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Phase 1: parallel chunking
|
|
234
|
+
all_chunks: list[CodeChunk] = []
|
|
235
|
+
new_mtimes: dict[str, float] = {}
|
|
236
|
+
|
|
237
|
+
def _safe_chunk(item: tuple[Path, float]) -> tuple[list[CodeChunk], str, float]:
|
|
238
|
+
fpath, mtime = item
|
|
239
|
+
try:
|
|
240
|
+
return chunk_file(str(fpath)), str(fpath), mtime
|
|
241
|
+
except Exception:
|
|
242
|
+
return [], str(fpath), mtime
|
|
243
|
+
|
|
244
|
+
with ThreadPoolExecutor(max_workers=_CHUNK_WORKERS) as pool:
|
|
245
|
+
futures = {pool.submit(_safe_chunk, item): item for item in to_index}
|
|
246
|
+
done = 0
|
|
247
|
+
for fut in as_completed(futures):
|
|
248
|
+
chunks, fpath_str, mtime = fut.result()
|
|
249
|
+
seen: set[str] = set()
|
|
250
|
+
for c in chunks:
|
|
251
|
+
if c.chunk_id not in seen:
|
|
252
|
+
seen.add(c.chunk_id)
|
|
253
|
+
all_chunks.append(c)
|
|
254
|
+
self._indexed_files.add(fpath_str)
|
|
255
|
+
new_mtimes[fpath_str] = mtime
|
|
256
|
+
done += 1
|
|
257
|
+
if done % 50 == 0 or done == len(to_index):
|
|
258
|
+
logger.info(" chunked %d/%d files (%d chunks so far)...",
|
|
259
|
+
done, len(to_index), len(all_chunks))
|
|
260
|
+
|
|
261
|
+
if not all_chunks:
|
|
262
|
+
self._last_indexed = time.time()
|
|
263
|
+
return len(self._indexed_files), self._collection.count()
|
|
264
|
+
|
|
265
|
+
# Phase 2: delete stale chunks for re-indexed files (no-op for brand-new files).
|
|
266
|
+
# Under force, mtime_cache is empty, so delete by file_path unconditionally
|
|
267
|
+
# to avoid leaving stale chunks whose ids no longer match (UPG-8.6). The
|
|
268
|
+
# purpose collection (ARCH-4) is keyed by the same file_path metadata, so
|
|
269
|
+
# its stale entries are dropped alongside the body collection's.
|
|
270
|
+
phase_start = time.time()
|
|
271
|
+
for fpath_str in new_mtimes:
|
|
272
|
+
if force or fpath_str in mtime_cache: # previously indexed → delete old chunks
|
|
273
|
+
try:
|
|
274
|
+
existing = self._collection.get(where={"file_path": fpath_str})
|
|
275
|
+
if existing["ids"]:
|
|
276
|
+
self._collection.delete(ids=existing["ids"])
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
try:
|
|
280
|
+
existing_p = self._purpose_collection.get(where={"file_path": fpath_str})
|
|
281
|
+
if existing_p["ids"]:
|
|
282
|
+
self._purpose_collection.delete(ids=existing_p["ids"])
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
logger.info(" stale-chunk sweep done: %d files in %.0fs",
|
|
286
|
+
len(new_mtimes), time.time() - phase_start)
|
|
287
|
+
|
|
288
|
+
# Phase 3: global batched embed + upsert
|
|
289
|
+
# Embed in large batches (256) for BLAS efficiency, upsert in smaller batches (100)
|
|
290
|
+
# to stay within SQLite's 999-variable limit (6 metadata fields × 100 rows = 600).
|
|
291
|
+
ids = [c.chunk_id for c in all_chunks]
|
|
292
|
+
documents = [c.content for c in all_chunks]
|
|
293
|
+
metadatas = [
|
|
294
|
+
{
|
|
295
|
+
"file_path": c.file_path,
|
|
296
|
+
"language": c.language,
|
|
297
|
+
"node_type": c.node_type,
|
|
298
|
+
"start_line": c.start_line,
|
|
299
|
+
"end_line": c.end_line,
|
|
300
|
+
"symbol_name": c.symbol_name,
|
|
301
|
+
}
|
|
302
|
+
for c in all_chunks
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
total = len(ids)
|
|
306
|
+
all_embeddings: list[list[float]] = []
|
|
307
|
+
phase_start = time.time()
|
|
308
|
+
for i in range(0, total, _EMBED_BATCH_SIZE):
|
|
309
|
+
batch_docs = documents[i: i + _EMBED_BATCH_SIZE]
|
|
310
|
+
all_embeddings.extend(self._embed_provider.embed(batch_docs))
|
|
311
|
+
if i % (10 * _EMBED_BATCH_SIZE) == 0 and i > 0:
|
|
312
|
+
logger.info(" embedded %d/%d chunks...", i, total)
|
|
313
|
+
logger.info(" content embed done: %d chunks in %.0fs",
|
|
314
|
+
total, time.time() - phase_start)
|
|
315
|
+
|
|
316
|
+
phase_start = time.time()
|
|
317
|
+
for i in range(0, total, _UPSERT_BATCH_SIZE):
|
|
318
|
+
self._collection.upsert(
|
|
319
|
+
ids=ids[i: i + _UPSERT_BATCH_SIZE],
|
|
320
|
+
documents=documents[i: i + _UPSERT_BATCH_SIZE],
|
|
321
|
+
metadatas=metadatas[i: i + _UPSERT_BATCH_SIZE],
|
|
322
|
+
embeddings=all_embeddings[i: i + _UPSERT_BATCH_SIZE],
|
|
323
|
+
)
|
|
324
|
+
if i % (50 * _UPSERT_BATCH_SIZE) == 0 and i > 0:
|
|
325
|
+
logger.info(" upserted %d/%d chunks...", i, total)
|
|
326
|
+
logger.info(" content upsert done: %d chunks in %.0fs",
|
|
327
|
+
total, time.time() - phase_start)
|
|
328
|
+
|
|
329
|
+
self._upsert_purpose_vectors(all_chunks)
|
|
330
|
+
|
|
331
|
+
logger.info("Indexed %d chunks from %d files", total, len(to_index))
|
|
332
|
+
|
|
333
|
+
# Persist mtime cache
|
|
334
|
+
mtime_cache.update(new_mtimes)
|
|
335
|
+
self._save_mtime_cache(mtime_cache)
|
|
336
|
+
self._write_embed_model_stamp()
|
|
337
|
+
|
|
338
|
+
self._last_indexed = time.time()
|
|
339
|
+
return len(self._indexed_files), self._collection.count()
|
|
340
|
+
|
|
341
|
+
def index_file(self, file_path: str) -> int:
|
|
342
|
+
"""Chunk and embed a single file. Returns number of chunks indexed."""
|
|
343
|
+
chunks = chunk_file(file_path)
|
|
344
|
+
if not chunks:
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
# Deduplicate by chunk_id (AST nodes on the same line range can collide in
|
|
348
|
+
# minified files like jquery.min.js where many nodes share line 2-2)
|
|
349
|
+
seen_ids: set[str] = set()
|
|
350
|
+
deduped: list = []
|
|
351
|
+
for c in chunks:
|
|
352
|
+
if c.chunk_id not in seen_ids:
|
|
353
|
+
seen_ids.add(c.chunk_id)
|
|
354
|
+
deduped.append(c)
|
|
355
|
+
chunks = deduped
|
|
356
|
+
|
|
357
|
+
# Remove old chunks for this file before re-indexing
|
|
358
|
+
self.delete_file(file_path)
|
|
359
|
+
|
|
360
|
+
ids = [c.chunk_id for c in chunks]
|
|
361
|
+
documents = [c.content for c in chunks]
|
|
362
|
+
metadatas = [
|
|
363
|
+
{
|
|
364
|
+
"file_path": c.file_path,
|
|
365
|
+
"language": c.language,
|
|
366
|
+
"node_type": c.node_type,
|
|
367
|
+
"start_line": c.start_line,
|
|
368
|
+
"end_line": c.end_line,
|
|
369
|
+
"symbol_name": c.symbol_name,
|
|
370
|
+
}
|
|
371
|
+
for c in chunks
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
# Embed in batches
|
|
375
|
+
all_embeddings: list[list[float]] = []
|
|
376
|
+
for i in range(0, len(documents), _FILE_BATCH_SIZE):
|
|
377
|
+
batch = documents[i: i + _FILE_BATCH_SIZE]
|
|
378
|
+
all_embeddings.extend(self._embed_provider.embed(batch))
|
|
379
|
+
assert len(all_embeddings) == len(ids), (
|
|
380
|
+
f"Embed provider returned {len(all_embeddings)} embeddings for {len(ids)} chunks"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
for i in range(0, len(ids), _FILE_BATCH_SIZE):
|
|
384
|
+
self._collection.upsert(
|
|
385
|
+
ids=ids[i: i + _FILE_BATCH_SIZE],
|
|
386
|
+
documents=documents[i: i + _FILE_BATCH_SIZE],
|
|
387
|
+
metadatas=metadatas[i: i + _FILE_BATCH_SIZE],
|
|
388
|
+
embeddings=all_embeddings[i: i + _FILE_BATCH_SIZE],
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
self._upsert_purpose_vectors(chunks)
|
|
392
|
+
|
|
393
|
+
self._indexed_files.add(file_path)
|
|
394
|
+
return len(chunks)
|
|
395
|
+
|
|
396
|
+
def delete_file(self, file_path: str) -> None:
|
|
397
|
+
"""Remove all chunks belonging to a file (body + purpose collections)."""
|
|
398
|
+
try:
|
|
399
|
+
existing = self._collection.get(where={"file_path": file_path})
|
|
400
|
+
if existing["ids"]:
|
|
401
|
+
self._collection.delete(ids=existing["ids"])
|
|
402
|
+
self._indexed_files.discard(file_path)
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
try:
|
|
406
|
+
existing_p = self._purpose_collection.get(where={"file_path": file_path})
|
|
407
|
+
if existing_p["ids"]:
|
|
408
|
+
self._purpose_collection.delete(ids=existing_p["ids"])
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# Purpose vectors (ARCH-4 dual-vector pool entry)
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def _upsert_purpose_vectors(self, chunks: list[CodeChunk]) -> None:
|
|
417
|
+
"""Embed + upsert the body-stripped purpose text for symbol chunks.
|
|
418
|
+
|
|
419
|
+
Skipped entirely when `DUAL_VECTOR_ENABLED` is False (config, default
|
|
420
|
+
on) — reduces to the pre-ARCH-4 body-only index. Non-symbol chunks
|
|
421
|
+
(`build_purpose_text` returns None) are not written — the purpose
|
|
422
|
+
collection stays a strict subset of the body collection's ids.
|
|
423
|
+
"""
|
|
424
|
+
if not _DUAL_VECTOR_ENABLED or not chunks:
|
|
425
|
+
return
|
|
426
|
+
ids: list[str] = []
|
|
427
|
+
documents: list[str] = []
|
|
428
|
+
metadatas: list[dict] = []
|
|
429
|
+
for c in chunks:
|
|
430
|
+
purpose_text = build_purpose_text(c.content, c.symbol_name, c.node_type, c.language)
|
|
431
|
+
if purpose_text is None:
|
|
432
|
+
continue
|
|
433
|
+
ids.append(c.chunk_id)
|
|
434
|
+
documents.append(purpose_text)
|
|
435
|
+
metadatas.append({
|
|
436
|
+
"file_path": c.file_path,
|
|
437
|
+
"language": c.language,
|
|
438
|
+
"node_type": c.node_type,
|
|
439
|
+
"start_line": c.start_line,
|
|
440
|
+
"end_line": c.end_line,
|
|
441
|
+
"symbol_name": c.symbol_name,
|
|
442
|
+
})
|
|
443
|
+
if not ids:
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
total = len(ids)
|
|
447
|
+
all_embeddings: list[list[float]] = []
|
|
448
|
+
phase_start = time.time()
|
|
449
|
+
for i in range(0, total, _EMBED_BATCH_SIZE):
|
|
450
|
+
all_embeddings.extend(self._embed_provider.embed(documents[i: i + _EMBED_BATCH_SIZE]))
|
|
451
|
+
if i % (10 * _EMBED_BATCH_SIZE) == 0 and i > 0:
|
|
452
|
+
logger.info(" purpose-embedded %d/%d symbol chunks...", i, total)
|
|
453
|
+
logger.info(" purpose embed done: %d symbol chunks in %.0fs",
|
|
454
|
+
total, time.time() - phase_start)
|
|
455
|
+
|
|
456
|
+
phase_start = time.time()
|
|
457
|
+
for i in range(0, total, _UPSERT_BATCH_SIZE):
|
|
458
|
+
self._purpose_collection.upsert(
|
|
459
|
+
ids=ids[i: i + _UPSERT_BATCH_SIZE],
|
|
460
|
+
documents=documents[i: i + _UPSERT_BATCH_SIZE],
|
|
461
|
+
metadatas=metadatas[i: i + _UPSERT_BATCH_SIZE],
|
|
462
|
+
embeddings=all_embeddings[i: i + _UPSERT_BATCH_SIZE],
|
|
463
|
+
)
|
|
464
|
+
logger.info(" purpose upsert done: %d symbol chunks in %.0fs",
|
|
465
|
+
total, time.time() - phase_start)
|
|
466
|
+
|
|
467
|
+
# ------------------------------------------------------------------
|
|
468
|
+
# mtime cache — tracks file modification times for incremental indexing
|
|
469
|
+
# ------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
def _mtime_cache_path(self) -> Path:
|
|
472
|
+
# Co-locate the mtime cache with the ChromaDB dir so the two always clear
|
|
473
|
+
# together. Previously this hardcoded ~/.cache/vectr/db/<hash> regardless
|
|
474
|
+
# of the db_path override, so when the collection lived elsewhere (e.g. the
|
|
475
|
+
# service's ~/.cache/vectr/<hash>/chroma) the cache could desync — a stale
|
|
476
|
+
# cache then reported "nothing to re-index" against an empty collection,
|
|
477
|
+
# leaving 0 chunks. (UPG-8.5)
|
|
478
|
+
return self._db_dir / "index_cache.json"
|
|
479
|
+
|
|
480
|
+
def _load_mtime_cache(self) -> dict[str, float]:
|
|
481
|
+
path = self._mtime_cache_path()
|
|
482
|
+
try:
|
|
483
|
+
cache = json.loads(path.read_text(encoding="utf-8"))
|
|
484
|
+
except (OSError, json.JSONDecodeError):
|
|
485
|
+
return {}
|
|
486
|
+
# Schema-version gate: a cache written by an older INDEXING_SCHEMA_VERSION
|
|
487
|
+
# (e.g. before ARCH-4's purpose vector existed) is treated as a cold
|
|
488
|
+
# cache — every file falls into `to_index` on the next index_workspace()
|
|
489
|
+
# and is fully re-chunked/re-embedded, the same recovery path force=True
|
|
490
|
+
# uses. This is what makes a pipeline change (new derived vector, new
|
|
491
|
+
# chunk content) reach an already-indexed workspace without a manual
|
|
492
|
+
# cache wipe. A cache with no version key at all (pre-ARCH-4, unversioned)
|
|
493
|
+
# is also treated as stale.
|
|
494
|
+
stored_version = cache.pop(_MTIME_CACHE_SCHEMA_KEY, None)
|
|
495
|
+
if stored_version != INDEXING_SCHEMA_VERSION:
|
|
496
|
+
return {}
|
|
497
|
+
return cache
|
|
498
|
+
|
|
499
|
+
def _save_mtime_cache(self, cache: dict[str, float]) -> None:
|
|
500
|
+
path = self._mtime_cache_path()
|
|
501
|
+
try:
|
|
502
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
payload = dict(cache)
|
|
504
|
+
payload[_MTIME_CACHE_SCHEMA_KEY] = INDEXING_SCHEMA_VERSION
|
|
505
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
506
|
+
except OSError:
|
|
507
|
+
pass
|
|
508
|
+
|
|
509
|
+
# ------------------------------------------------------------------
|
|
510
|
+
# Embedding-model version stamp (UPG-EMBEDDER-SWAP-GRANITE) — see
|
|
511
|
+
# _EMBED_MODEL_STAMP_FILE's docstring for why this is a separate file
|
|
512
|
+
# from the mtime cache rather than another key inside it.
|
|
513
|
+
# ------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
def _embed_model_stamp_path(self) -> Path:
|
|
516
|
+
return self._db_dir / _EMBED_MODEL_STAMP_FILE
|
|
517
|
+
|
|
518
|
+
def _stored_embed_model(self) -> str | None:
|
|
519
|
+
"""The embed_model that built the collection's CURRENT vectors, or
|
|
520
|
+
None if no stamp exists yet (fresh index, or a pre-existing index
|
|
521
|
+
from a vectr version that predates this mechanism — callers must
|
|
522
|
+
treat None as a mismatch, not a match)."""
|
|
523
|
+
path = self._embed_model_stamp_path()
|
|
524
|
+
try:
|
|
525
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
526
|
+
except (OSError, json.JSONDecodeError):
|
|
527
|
+
return None
|
|
528
|
+
model = data.get("embed_model")
|
|
529
|
+
return str(model) if model else None
|
|
530
|
+
|
|
531
|
+
def _write_embed_model_stamp(self) -> None:
|
|
532
|
+
path = self._embed_model_stamp_path()
|
|
533
|
+
try:
|
|
534
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
path.write_text(json.dumps({"embed_model": self.embed_model}), encoding="utf-8")
|
|
536
|
+
except OSError:
|
|
537
|
+
pass
|
|
538
|
+
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
# Orphan pruning — reconcile the collection against the current walk
|
|
541
|
+
# ------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
def _collection_file_paths(self) -> set[str]:
|
|
544
|
+
"""Distinct file_path values currently stored in the collection.
|
|
545
|
+
|
|
546
|
+
Metadata-only paginated scan (no documents/embeddings loaded)."""
|
|
547
|
+
_PAGE = 1000
|
|
548
|
+
paths: set[str] = set()
|
|
549
|
+
offset = 0
|
|
550
|
+
while True:
|
|
551
|
+
page = self._collection.get(include=["metadatas"], limit=_PAGE, offset=offset)
|
|
552
|
+
ids = page["ids"]
|
|
553
|
+
if not ids:
|
|
554
|
+
break
|
|
555
|
+
for meta in page["metadatas"]:
|
|
556
|
+
fp = meta.get("file_path")
|
|
557
|
+
if fp:
|
|
558
|
+
paths.add(fp)
|
|
559
|
+
offset += len(ids)
|
|
560
|
+
if len(ids) < _PAGE:
|
|
561
|
+
break
|
|
562
|
+
return paths
|
|
563
|
+
|
|
564
|
+
def _prune_orphaned_chunks(
|
|
565
|
+
self, should_index_paths: set[str], mtime_cache: dict[str, float],
|
|
566
|
+
) -> int:
|
|
567
|
+
"""Delete chunks for indexed files no longer in the walk set. Returns count.
|
|
568
|
+
|
|
569
|
+
Files become orphaned when they're newly excluded (.vectrignore/.gitignore),
|
|
570
|
+
deleted, or moved out of all roots. `mtime_cache` is mutated in place so the
|
|
571
|
+
pruned entries don't linger and re-trigger a phantom skip. (UPG-8.4)
|
|
572
|
+
"""
|
|
573
|
+
try:
|
|
574
|
+
indexed_paths = self._collection_file_paths()
|
|
575
|
+
except Exception:
|
|
576
|
+
return 0
|
|
577
|
+
orphaned = indexed_paths - should_index_paths
|
|
578
|
+
for path in orphaned:
|
|
579
|
+
self.delete_file(path)
|
|
580
|
+
mtime_cache.pop(path, None)
|
|
581
|
+
if orphaned:
|
|
582
|
+
logger.info("Pruned chunks for %d orphaned/excluded file(s)", len(orphaned))
|
|
583
|
+
return len(orphaned)
|
|
584
|
+
|
|
585
|
+
# ------------------------------------------------------------------
|
|
586
|
+
# Stats
|
|
587
|
+
# ------------------------------------------------------------------
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def total_chunks(self) -> int:
|
|
591
|
+
return self._collection.count()
|
|
592
|
+
|
|
593
|
+
@property
|
|
594
|
+
def last_indexed_ts(self) -> float:
|
|
595
|
+
return self._last_indexed
|
|
596
|
+
|
|
597
|
+
@property
|
|
598
|
+
def indexed_file_count(self) -> int:
|
|
599
|
+
return len(self._indexed_files)
|
|
600
|
+
|
|
601
|
+
@property
|
|
602
|
+
def indexed_file_paths(self) -> list[str]:
|
|
603
|
+
"""Return a copy of all file paths currently in the index."""
|
|
604
|
+
return list(self._indexed_files)
|
|
605
|
+
|
|
606
|
+
def indexed_language_stats(self) -> dict[str, dict[str, int]]:
|
|
607
|
+
"""Per-language coverage in the collection: `{lang: {"files", "chunks"}}`.
|
|
608
|
+
|
|
609
|
+
The ground truth for what the index actually contains — derived from chunk
|
|
610
|
+
metadata, not a fixed allow-list. One metadata-only paginated scan, cached
|
|
611
|
+
against the live chunk count so it only rescans when the index changes.
|
|
612
|
+
UPG-3.1 (which languages exist) and UPG-3.3 (per-language coverage +
|
|
613
|
+
symbol availability) both read from this single source.
|
|
614
|
+
"""
|
|
615
|
+
count = self.total_chunks
|
|
616
|
+
if self._lang_cache is not None and self._lang_cache[0] == count:
|
|
617
|
+
return self._lang_cache[1]
|
|
618
|
+
_PAGE = 1000
|
|
619
|
+
chunks: dict[str, int] = {}
|
|
620
|
+
files: dict[str, set[str]] = {}
|
|
621
|
+
offset = 0
|
|
622
|
+
while True:
|
|
623
|
+
page = self._collection.get(include=["metadatas"], limit=_PAGE, offset=offset)
|
|
624
|
+
ids = page["ids"]
|
|
625
|
+
if not ids:
|
|
626
|
+
break
|
|
627
|
+
for meta in page["metadatas"]:
|
|
628
|
+
lang = meta.get("language")
|
|
629
|
+
if not lang:
|
|
630
|
+
continue
|
|
631
|
+
chunks[lang] = chunks.get(lang, 0) + 1
|
|
632
|
+
fp = meta.get("file_path")
|
|
633
|
+
if fp:
|
|
634
|
+
files.setdefault(lang, set()).add(fp)
|
|
635
|
+
offset += len(ids)
|
|
636
|
+
if len(ids) < _PAGE:
|
|
637
|
+
break
|
|
638
|
+
stats = {
|
|
639
|
+
lang: {"files": len(files.get(lang, ())), "chunks": n}
|
|
640
|
+
for lang, n in chunks.items()
|
|
641
|
+
}
|
|
642
|
+
self._lang_cache = (count, stats)
|
|
643
|
+
return stats
|
|
644
|
+
|
|
645
|
+
def indexed_languages(self) -> list[str]:
|
|
646
|
+
"""Distinct, sorted `language` values currently in the collection (UPG-3.1)."""
|
|
647
|
+
return sorted(self.indexed_language_stats())
|
|
648
|
+
|
|
649
|
+
def embed_query(self, text: str) -> list[float]:
|
|
650
|
+
"""Embed a single search-query string. Public method for external callers.
|
|
651
|
+
|
|
652
|
+
Uses the embed provider's query mode (`embed_query`), not `embed` — for
|
|
653
|
+
asymmetric models (the default arctic-embed) this applies the model's
|
|
654
|
+
registered query prompt, which is required for correct dense retrieval and
|
|
655
|
+
must never be applied on the document/indexing side.
|
|
656
|
+
"""
|
|
657
|
+
return self._embed_provider.embed_query([text])[0]
|
|
658
|
+
|
|
659
|
+
def embed_query_batch(self, texts: list[str]) -> list[list[float]]:
|
|
660
|
+
"""Embed a batch of search-query strings using the provider's query mode.
|
|
661
|
+
|
|
662
|
+
For working-memory recall, where a query is matched against previously
|
|
663
|
+
stored note embeddings — same query/document asymmetry as code search.
|
|
664
|
+
"""
|
|
665
|
+
return self._embed_provider.embed_query(texts)
|
|
666
|
+
|
|
667
|
+
def embed_texts(self, texts: list[str]) -> list[list[float]]:
|
|
668
|
+
"""Embed a batch of texts (document/indexing side) using the configured
|
|
669
|
+
embed provider. Never applies a query prompt — for chunk/note content being
|
|
670
|
+
stored, not for search queries."""
|
|
671
|
+
return self._embed_provider.embed(texts)
|
|
672
|
+
|
|
673
|
+
@property
|
|
674
|
+
def chroma_client(self):
|
|
675
|
+
"""The underlying ChromaDB client — shared with the working memory collection."""
|
|
676
|
+
return self._client
|
|
677
|
+
|
|
678
|
+
def get_all_documents(self) -> tuple[list[str], list[str], list[dict]]:
|
|
679
|
+
"""Return (ids, documents, metadatas) for all stored chunks — used by BM25 index.
|
|
680
|
+
|
|
681
|
+
Paginates in batches of 500 to avoid ChromaDB's SQLite variable limit (~999).
|
|
682
|
+
"""
|
|
683
|
+
_PAGE = 500
|
|
684
|
+
all_ids: list[str] = []
|
|
685
|
+
all_docs: list[str] = []
|
|
686
|
+
all_meta: list[dict] = []
|
|
687
|
+
offset = 0
|
|
688
|
+
while True:
|
|
689
|
+
page = self._collection.get(
|
|
690
|
+
include=["documents", "metadatas"],
|
|
691
|
+
limit=_PAGE,
|
|
692
|
+
offset=offset,
|
|
693
|
+
)
|
|
694
|
+
batch_ids = page["ids"]
|
|
695
|
+
if not batch_ids:
|
|
696
|
+
break
|
|
697
|
+
all_ids.extend(batch_ids)
|
|
698
|
+
all_docs.extend(page["documents"])
|
|
699
|
+
all_meta.extend(page["metadatas"])
|
|
700
|
+
offset += len(batch_ids)
|
|
701
|
+
if len(batch_ids) < _PAGE:
|
|
702
|
+
break
|
|
703
|
+
return all_ids, all_docs, all_meta
|
|
704
|
+
|
|
705
|
+
def query_vector(
|
|
706
|
+
self,
|
|
707
|
+
query_embedding: list[float],
|
|
708
|
+
n_results: int = 10,
|
|
709
|
+
language: str | None = None,
|
|
710
|
+
languages: list[str] | None = None,
|
|
711
|
+
) -> dict:
|
|
712
|
+
# `languages` (a set/list) takes precedence over a single `language`:
|
|
713
|
+
# restricts the vector search to any of the given languages via an $in
|
|
714
|
+
# filter. Used by the doc-intent pool reservation (UPG-15.13) to fetch
|
|
715
|
+
# documentation-prose chunks that embed below the unfiltered fetch depth.
|
|
716
|
+
if languages:
|
|
717
|
+
where = {"language": {"$in": list(languages)}}
|
|
718
|
+
elif language:
|
|
719
|
+
where = {"language": language}
|
|
720
|
+
else:
|
|
721
|
+
where = None
|
|
722
|
+
return self._collection.query(
|
|
723
|
+
query_embeddings=[query_embedding],
|
|
724
|
+
n_results=min(n_results, max(1, self._collection.count())),
|
|
725
|
+
where=where,
|
|
726
|
+
include=["documents", "metadatas", "distances"],
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
def get_chunk_cosine_similarities(
|
|
730
|
+
self, query_embedding: list[float], chunk_ids: list[str]
|
|
731
|
+
) -> dict[str, float]:
|
|
732
|
+
"""Return cosine similarity scores for a specific set of chunk IDs.
|
|
733
|
+
|
|
734
|
+
Used by the forced-inclusion relevance gate (UPG-11.12) to check vector
|
|
735
|
+
similarity for non-compound candidates that are not in the natural pool.
|
|
736
|
+
ChromaDB embeddings are L2-normalised by default, so cosine = dot product.
|
|
737
|
+
|
|
738
|
+
Returns a dict {chunk_id: cosine_similarity}. Chunk IDs not found in the
|
|
739
|
+
collection are absent from the result (similarity treated as 0.0 by caller).
|
|
740
|
+
"""
|
|
741
|
+
if not chunk_ids or not query_embedding:
|
|
742
|
+
return {}
|
|
743
|
+
try:
|
|
744
|
+
batch = self._collection.get(ids=chunk_ids, include=["embeddings"])
|
|
745
|
+
except Exception:
|
|
746
|
+
return {}
|
|
747
|
+
result: dict[str, float] = {}
|
|
748
|
+
import numpy as np
|
|
749
|
+
q = np.asarray(query_embedding, dtype=np.float32)
|
|
750
|
+
q_norm = float(np.linalg.norm(q))
|
|
751
|
+
if q_norm == 0.0:
|
|
752
|
+
return result
|
|
753
|
+
# batch["embeddings"] may be a numpy array — avoid truthiness check on it.
|
|
754
|
+
embeddings_raw = batch.get("embeddings")
|
|
755
|
+
ids_raw = batch.get("ids")
|
|
756
|
+
if embeddings_raw is None or ids_raw is None:
|
|
757
|
+
return result
|
|
758
|
+
embeddings_list = list(embeddings_raw) if not isinstance(embeddings_raw, list) else embeddings_raw
|
|
759
|
+
ids_list = list(ids_raw) if not isinstance(ids_raw, list) else ids_raw
|
|
760
|
+
for cid, emb_raw in zip(ids_list, embeddings_list):
|
|
761
|
+
if emb_raw is None:
|
|
762
|
+
continue
|
|
763
|
+
emb = np.asarray(emb_raw, dtype=np.float32)
|
|
764
|
+
if emb.size == 0:
|
|
765
|
+
continue
|
|
766
|
+
dot = float(np.dot(q, emb))
|
|
767
|
+
e_norm = float(np.linalg.norm(emb))
|
|
768
|
+
if e_norm == 0.0:
|
|
769
|
+
continue
|
|
770
|
+
result[cid] = dot / (q_norm * e_norm)
|
|
771
|
+
return result
|
|
772
|
+
|
|
773
|
+
def query_vector_purpose(
|
|
774
|
+
self,
|
|
775
|
+
query_embedding: list[float],
|
|
776
|
+
n_results: int = 10,
|
|
777
|
+
language: str | None = None,
|
|
778
|
+
languages: list[str] | None = None,
|
|
779
|
+
) -> dict:
|
|
780
|
+
"""Query the purpose-vector collection (ARCH-4 dual-vector pool entry).
|
|
781
|
+
|
|
782
|
+
Mirrors `query_vector` but against `self._purpose_collection`, which only
|
|
783
|
+
holds symbol-bearing chunks (see `_upsert_purpose_vectors`). A workspace
|
|
784
|
+
indexed before ARCH-4 shipped has an empty purpose collection — `count()`
|
|
785
|
+
guards against ChromaDB raising on a query against zero rows, so old
|
|
786
|
+
indexes degrade gracefully to body-only results until reindexed.
|
|
787
|
+
"""
|
|
788
|
+
count = self._purpose_collection.count()
|
|
789
|
+
if count == 0:
|
|
790
|
+
return {"ids": [[]], "distances": [[]], "documents": [[]], "metadatas": [[]]}
|
|
791
|
+
if languages:
|
|
792
|
+
where = {"language": {"$in": list(languages)}}
|
|
793
|
+
elif language:
|
|
794
|
+
where = {"language": language}
|
|
795
|
+
else:
|
|
796
|
+
where = None
|
|
797
|
+
return self._purpose_collection.query(
|
|
798
|
+
query_embeddings=[query_embedding],
|
|
799
|
+
n_results=min(n_results, max(1, count)),
|
|
800
|
+
where=where,
|
|
801
|
+
include=["documents", "metadatas", "distances"],
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def fetch_chunks(self, ids: list[str]) -> list[dict]:
|
|
805
|
+
"""Deterministic re-fetch by chunk id (UPG-CTX-EVICT part a).
|
|
806
|
+
|
|
807
|
+
A direct ``self._collection.get(ids=...)`` — no embedding, no rerank,
|
|
808
|
+
no ranking of any kind. Every rendered search result carries its
|
|
809
|
+
chunk's exact ChromaDB id (``file_path:start_line-end_line``); this is
|
|
810
|
+
the counterpart lookup that restores the full chunk from that id
|
|
811
|
+
alone, so a chunk cleared from the caller's context (by harness tool-
|
|
812
|
+
result eviction, ``/compact``, or an API context-editing tombstone)
|
|
813
|
+
never requires a re-search or a blind file re-read.
|
|
814
|
+
|
|
815
|
+
Returns one dict per requested id, in REQUEST order (Chroma's own
|
|
816
|
+
``get(ids=...)`` does not preserve order and silently omits ids it
|
|
817
|
+
doesn't have — both are corrected here):
|
|
818
|
+
{"id": ..., "found": True, "file_path": ..., "start_line": ...,
|
|
819
|
+
"end_line": ..., "symbol_name": ..., "language": ..., "content": ...}
|
|
820
|
+
{"id": ..., "found": False}
|
|
821
|
+
|
|
822
|
+
Raises ValueError if `ids` exceeds FETCH_MAX_IDS_PER_CALL.
|
|
823
|
+
"""
|
|
824
|
+
from agent.config import FETCH_MAX_IDS_PER_CALL
|
|
825
|
+
if len(ids) > FETCH_MAX_IDS_PER_CALL:
|
|
826
|
+
raise ValueError(
|
|
827
|
+
f"Too many ids requested ({len(ids)}) — vectr_fetch accepts at "
|
|
828
|
+
f"most {FETCH_MAX_IDS_PER_CALL} per call."
|
|
829
|
+
)
|
|
830
|
+
if not ids:
|
|
831
|
+
return []
|
|
832
|
+
batch = self._collection.get(ids=ids, include=["documents", "metadatas"])
|
|
833
|
+
found_ids = batch.get("ids") or []
|
|
834
|
+
found_docs = batch.get("documents") or []
|
|
835
|
+
found_metas = batch.get("metadatas") or []
|
|
836
|
+
by_id: dict[str, tuple[str, dict]] = {
|
|
837
|
+
cid: (doc, meta) for cid, doc, meta in zip(found_ids, found_docs, found_metas)
|
|
838
|
+
}
|
|
839
|
+
results: list[dict] = []
|
|
840
|
+
for requested_id in ids:
|
|
841
|
+
hit = by_id.get(requested_id)
|
|
842
|
+
if hit is None:
|
|
843
|
+
results.append({"id": requested_id, "found": False})
|
|
844
|
+
continue
|
|
845
|
+
doc, meta = hit
|
|
846
|
+
results.append({
|
|
847
|
+
"id": requested_id,
|
|
848
|
+
"found": True,
|
|
849
|
+
"file_path": meta.get("file_path", ""),
|
|
850
|
+
"start_line": int(meta.get("start_line", 0)),
|
|
851
|
+
"end_line": int(meta.get("end_line", 0)),
|
|
852
|
+
"symbol_name": meta.get("symbol_name", ""),
|
|
853
|
+
"language": meta.get("language", ""),
|
|
854
|
+
"content": doc,
|
|
855
|
+
})
|
|
856
|
+
return results
|
|
857
|
+
|
|
858
|
+
def get_chunk_documents(self, chunk_ids: list[str]) -> dict[str, tuple[str, dict]]:
|
|
859
|
+
"""Batch-fetch (document, metadata) from the body collection for given ids.
|
|
860
|
+
|
|
861
|
+
Used to backfill body content/metadata for chunk ids that were only
|
|
862
|
+
discovered via the purpose-vector query (ARCH-4) and therefore did not
|
|
863
|
+
come back from the body `query_vector` call in the same search pass.
|
|
864
|
+
Ids not found in the body collection are absent from the result.
|
|
865
|
+
"""
|
|
866
|
+
if not chunk_ids:
|
|
867
|
+
return {}
|
|
868
|
+
try:
|
|
869
|
+
batch = self._collection.get(ids=chunk_ids, include=["documents", "metadatas"])
|
|
870
|
+
except Exception:
|
|
871
|
+
return {}
|
|
872
|
+
ids_raw = batch.get("ids") or []
|
|
873
|
+
docs_raw = batch.get("documents") or []
|
|
874
|
+
metas_raw = batch.get("metadatas") or []
|
|
875
|
+
return {
|
|
876
|
+
cid: (doc, meta)
|
|
877
|
+
for cid, doc, meta in zip(ids_raw, docs_raw, metas_raw)
|
|
878
|
+
}
|