attocode-code-intel 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.
- attocode_code_intel-0.1.0.dist-info/METADATA +221 -0
- attocode_code_intel-0.1.0.dist-info/RECORD +345 -0
- attocode_code_intel-0.1.0.dist-info/WHEEL +4 -0
- attocode_code_intel-0.1.0.dist-info/entry_points.txt +2 -0
- attocode_code_intel-0.1.0.dist-info/licenses/LICENSE +21 -0
- attocode_intel/GUIDELINES.md +432 -0
- attocode_intel/__init__.py +52 -0
- attocode_intel/_internal/__init__.py +1 -0
- attocode_intel/_internal/errors/__init__.py +126 -0
- attocode_intel/_internal/integrations/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/__init__.py +1 -0
- attocode_intel/_internal/integrations/context/architecture_drift.py +354 -0
- attocode_intel/_internal/integrations/context/ast_chunker.py +202 -0
- attocode_intel/_internal/integrations/context/ast_service.py +1483 -0
- attocode_intel/_internal/integrations/context/blob_oid.py +423 -0
- attocode_intel/_internal/integrations/context/cache_manifest.py +221 -0
- attocode_intel/_internal/integrations/context/cas.py +466 -0
- attocode_intel/_internal/integrations/context/code_analyzer.py +528 -0
- attocode_intel/_internal/integrations/context/codebase_ast.py +1461 -0
- attocode_intel/_internal/integrations/context/codebase_context.py +1736 -0
- attocode_intel/_internal/integrations/context/cross_mode.py +263 -0
- attocode_intel/_internal/integrations/context/cross_references.py +616 -0
- attocode_intel/_internal/integrations/context/embedding_rotation.py +597 -0
- attocode_intel/_internal/integrations/context/embeddings.py +282 -0
- attocode_intel/_internal/integrations/context/frecency.py +427 -0
- attocode_intel/_internal/integrations/context/fuzzy.py +320 -0
- attocode_intel/_internal/integrations/context/hierarchical_explorer.py +331 -0
- attocode_intel/_internal/integrations/context/hydration.py +90 -0
- attocode_intel/_internal/integrations/context/index_repair.py +87 -0
- attocode_intel/_internal/integrations/context/index_store.py +586 -0
- attocode_intel/_internal/integrations/context/memory_store.py +602 -0
- attocode_intel/_internal/integrations/context/module_resolution.py +52 -0
- attocode_intel/_internal/integrations/context/query_constraints.py +361 -0
- attocode_intel/_internal/integrations/context/query_history.py +417 -0
- attocode_intel/_internal/integrations/context/reranker.py +351 -0
- attocode_intel/_internal/integrations/context/semantic_search.py +2931 -0
- attocode_intel/_internal/integrations/context/syntax_evidence.py +94 -0
- attocode_intel/_internal/integrations/context/systemone_ranker.py +245 -0
- attocode_intel/_internal/integrations/context/temporal_coupling.py +411 -0
- attocode_intel/_internal/integrations/context/trigram_index.py +820 -0
- attocode_intel/_internal/integrations/context/trigram_regex.py +171 -0
- attocode_intel/_internal/integrations/context/ts_parser.py +2199 -0
- attocode_intel/_internal/integrations/context/vector_store.py +795 -0
- attocode_intel/_internal/integrations/feature_flags/__init__.py +357 -0
- attocode_intel/_internal/integrations/lsp/__init__.py +1 -0
- attocode_intel/_internal/integrations/lsp/client.py +1140 -0
- attocode_intel/_internal/integrations/mcp/__init__.py +1 -0
- attocode_intel/_internal/integrations/mcp/client.py +253 -0
- attocode_intel/_internal/integrations/security/__init__.py +1 -0
- attocode_intel/_internal/integrations/security/custom_rules.py +176 -0
- attocode_intel/_internal/integrations/security/dataflow.py +522 -0
- attocode_intel/_internal/integrations/security/dependency_audit.py +303 -0
- attocode_intel/_internal/integrations/security/matcher.py +44 -0
- attocode_intel/_internal/integrations/security/patterns.py +724 -0
- attocode_intel/_internal/integrations/security/scanner.py +351 -0
- attocode_intel/_internal/integrations/utilities/__init__.py +1 -0
- attocode_intel/_internal/integrations/utilities/ignore.py +64 -0
- attocode_intel/_internal/integrations/utilities/token_estimate.py +51 -0
- attocode_intel/_internal/tricks/__init__.py +1 -0
- attocode_intel/_internal/tricks/recursive_context.py +357 -0
- attocode_intel/_internal/types/__init__.py +1 -0
- attocode_intel/_internal/types/events.py +289 -0
- attocode_intel/_internal/types/severity.py +15 -0
- attocode_intel/_shared.py +341 -0
- attocode_intel/analysis_coverage.py +56 -0
- attocode_intel/api/__init__.py +1 -0
- attocode_intel/api/access.py +32 -0
- attocode_intel/api/app.py +247 -0
- attocode_intel/api/auth/__init__.py +128 -0
- attocode_intel/api/auth/api_keys.py +61 -0
- attocode_intel/api/auth/context.py +34 -0
- attocode_intel/api/auth/jwt.py +66 -0
- attocode_intel/api/auth/oauth.py +146 -0
- attocode_intel/api/auth/passwords.py +15 -0
- attocode_intel/api/deps.py +351 -0
- attocode_intel/api/knowledge_bridge.py +13 -0
- attocode_intel/api/mcp_transport.py +51 -0
- attocode_intel/api/middleware.py +411 -0
- attocode_intel/api/models.py +698 -0
- attocode_intel/api/providers/__init__.py +1 -0
- attocode_intel/api/providers/db_provider.py +1106 -0
- attocode_intel/api/providers/local_provider.py +476 -0
- attocode_intel/api/providers/remote_provider.py +740 -0
- attocode_intel/api/repository_source.py +21 -0
- attocode_intel/api/routes/__init__.py +1 -0
- attocode_intel/api/routes/_pin_helper.py +158 -0
- attocode_intel/api/routes/activity.py +148 -0
- attocode_intel/api/routes/adr.py +102 -0
- attocode_intel/api/routes/analysis.py +515 -0
- attocode_intel/api/routes/api_keys.py +170 -0
- attocode_intel/api/routes/auth.py +428 -0
- attocode_intel/api/routes/branches.py +262 -0
- attocode_intel/api/routes/cross_repo_search.py +198 -0
- attocode_intel/api/routes/embeddings.py +386 -0
- attocode_intel/api/routes/files.py +132 -0
- attocode_intel/api/routes/files_v2.py +388 -0
- attocode_intel/api/routes/gc.py +322 -0
- attocode_intel/api/routes/git_v2.py +433 -0
- attocode_intel/api/routes/graph.py +174 -0
- attocode_intel/api/routes/graph_viz.py +160 -0
- attocode_intel/api/routes/health.py +62 -0
- attocode_intel/api/routes/history.py +175 -0
- attocode_intel/api/routes/jobs.py +84 -0
- attocode_intel/api/routes/learning.py +205 -0
- attocode_intel/api/routes/lsp.py +272 -0
- attocode_intel/api/routes/notify.py +623 -0
- attocode_intel/api/routes/operations.py +27 -0
- attocode_intel/api/routes/orgs.py +924 -0
- attocode_intel/api/routes/preferences.py +100 -0
- attocode_intel/api/routes/presence.py +42 -0
- attocode_intel/api/routes/projects.py +116 -0
- attocode_intel/api/routes/repos.py +47 -0
- attocode_intel/api/routes/search.py +180 -0
- attocode_intel/api/routes/snapshots.py +614 -0
- attocode_intel/api/routes/webhooks.py +313 -0
- attocode_intel/api/routes/websocket.py +116 -0
- attocode_intel/api/static/graph.html +239 -0
- attocode_intel/api/utils.py +96 -0
- attocode_intel/artifacts/__init__.py +45 -0
- attocode_intel/artifacts/hashing.py +230 -0
- attocode_intel/artifacts/provenance.py +190 -0
- attocode_intel/artifacts/retrieval_pin.py +159 -0
- attocode_intel/audit.py +59 -0
- attocode_intel/bug_finder.py +206 -0
- attocode_intel/bundle.py +80 -0
- attocode_intel/catalog.py +242 -0
- attocode_intel/change_review.py +75 -0
- attocode_intel/cli.py +3062 -0
- attocode_intel/community.py +63 -0
- attocode_intel/confidence/__init__.py +144 -0
- attocode_intel/confidence/jev.py +234 -0
- attocode_intel/confidence/llm.py +330 -0
- attocode_intel/confidence/redact.py +44 -0
- attocode_intel/confidence/settings.py +74 -0
- attocode_intel/config.py +256 -0
- attocode_intel/crypto.py +31 -0
- attocode_intel/db/__init__.py +7 -0
- attocode_intel/db/base.py +33 -0
- attocode_intel/db/engine.py +61 -0
- attocode_intel/db/models.py +529 -0
- attocode_intel/entrypoint.py +185 -0
- attocode_intel/focused_evidence.py +130 -0
- attocode_intel/freshness.py +154 -0
- attocode_intel/gateway.py +734 -0
- attocode_intel/git/__init__.py +7 -0
- attocode_intel/git/credentials.py +75 -0
- attocode_intel/git/manager.py +493 -0
- attocode_intel/git/models.py +96 -0
- attocode_intel/git/storage.py +98 -0
- attocode_intel/graph_query_parser.py +761 -0
- attocode_intel/helpers.py +712 -0
- attocode_intel/indexing/__init__.py +29 -0
- attocode_intel/indexing/debouncer.py +136 -0
- attocode_intel/indexing/delta_indexer.py +151 -0
- attocode_intel/indexing/full_indexer.py +323 -0
- attocode_intel/indexing/incremental.py +240 -0
- attocode_intel/indexing/parser.py +403 -0
- attocode_intel/indexing/summarizer.py +264 -0
- attocode_intel/installer.py +1896 -0
- attocode_intel/knowledge.py +269 -0
- attocode_intel/local_knowledge.py +77 -0
- attocode_intel/migrations/alembic.ini +36 -0
- attocode_intel/migrations/env.py +71 -0
- attocode_intel/migrations/script.py.mako +27 -0
- attocode_intel/migrations/versions/001_initial_schema.py +219 -0
- attocode_intel/migrations/versions/002_branch_version_and_embeddings.py +61 -0
- attocode_intel/migrations/versions/003_embedding_uniqueness_and_cascade.py +51 -0
- attocode_intel/migrations/versions/004_audit_events.py +38 -0
- attocode_intel/migrations/versions/005_user_preferences.py +30 -0
- attocode_intel/migrations/versions/006_google_oauth.py +27 -0
- attocode_intel/migrations/versions/007_pgvector_embeddings.py +28 -0
- attocode_intel/migrations/versions/008_symbol_references.py +37 -0
- attocode_intel/migrations/versions/009_webhook_secret_encryption.py +25 -0
- attocode_intel/migrations/versions/010_add_commits_table.py +40 -0
- attocode_intel/migrations/versions/011_commit_changed_files.py +25 -0
- attocode_intel/migrations/versions/012_branch_merged_at.py +25 -0
- attocode_intel/migrations/versions/013_revoked_tokens.py +32 -0
- attocode_intel/migrations/versions/014_blame_hunks.py +40 -0
- attocode_intel/migrations/versions/015_commit_file_stats.py +40 -0
- attocode_intel/migrations/versions/016_embedding_provenance_columns.py +179 -0
- attocode_intel/migrations/versions/017_provenance_table.py +102 -0
- attocode_intel/migrations/versions/018_repo_snapshots.py +178 -0
- attocode_intel/migrations/versions/019_symref_caller.py +42 -0
- attocode_intel/migrations/versions/020_shared_knowledge.py +29 -0
- attocode_intel/onboarding.py +258 -0
- attocode_intel/output.py +178 -0
- attocode_intel/precision.py +133 -0
- attocode_intel/presence.py +160 -0
- attocode_intel/probe.py +110 -0
- attocode_intel/project_dir.py +97 -0
- attocode_intel/pubsub.py +113 -0
- attocode_intel/query_ranking.py +242 -0
- attocode_intel/readiness.py +972 -0
- attocode_intel/redis.py +51 -0
- attocode_intel/reference_pages.py +60 -0
- attocode_intel/remote.py +131 -0
- attocode_intel/repo_ranker.py +246 -0
- attocode_intel/request_context.py +82 -0
- attocode_intel/rules/__init__.py +28 -0
- attocode_intel/rules/ci.py +320 -0
- attocode_intel/rules/combinators.py +293 -0
- attocode_intel/rules/enricher.py +127 -0
- attocode_intel/rules/evolution.py +452 -0
- attocode_intel/rules/executor.py +531 -0
- attocode_intel/rules/filters/__init__.py +5 -0
- attocode_intel/rules/filters/pipeline.py +151 -0
- attocode_intel/rules/formatter.py +198 -0
- attocode_intel/rules/hygiene.py +242 -0
- attocode_intel/rules/importers/__init__.py +1 -0
- attocode_intel/rules/importers/semgrep.py +382 -0
- attocode_intel/rules/loader.py +292 -0
- attocode_intel/rules/marketplace.py +375 -0
- attocode_intel/rules/metavar.py +181 -0
- attocode_intel/rules/model.py +317 -0
- attocode_intel/rules/packs/__init__.py +1 -0
- attocode_intel/rules/packs/community/_license_templates/Apache-2.0.txt +176 -0
- attocode_intel/rules/packs/community/_license_templates/MIT.txt +21 -0
- attocode_intel/rules/packs/community/bandit-python/LICENSE +176 -0
- attocode_intel/rules/packs/community/bandit-python/NOTICE +13 -0
- attocode_intel/rules/packs/community/bandit-python/PORTING.md +23 -0
- attocode_intel/rules/packs/community/bandit-python/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B105-hardcoded-password.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B303-weak-md5.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B304-weak-des.yaml +20 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B307-use-of-eval.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B324-weak-sha1.yaml +21 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B501-no-cert-validation.yaml +23 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B602-shell-true.yaml +22 -0
- attocode_intel/rules/packs/community/bandit-python/rules/B608-sqli.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/LICENSE +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/NOTICE +13 -0
- attocode_intel/rules/packs/community/eslint-typescript/PORTING.md +23 -0
- attocode_intel/rules/packs/community/eslint-typescript/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/eqeqeq.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-eval.yaml +22 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-implied-eval.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-new-func.yaml +21 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-throw-literal.yaml +19 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-var.yaml +20 -0
- attocode_intel/rules/packs/community/eslint-typescript/rules/no-with.yaml +18 -0
- attocode_intel/rules/packs/community/gosec-go/LICENSE +176 -0
- attocode_intel/rules/packs/community/gosec-go/NOTICE +13 -0
- attocode_intel/rules/packs/community/gosec-go/PORTING.md +23 -0
- attocode_intel/rules/packs/community/gosec-go/manifest.yaml +14 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G101-hardcoded-credentials.yaml +22 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G102-bind-all-interfaces.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G103-unsafe-block.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G201-sql-format.yaml +21 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G304-file-inclusion.yaml +23 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G401-weak-cipher.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G402-tls-min-version.yaml +20 -0
- attocode_intel/rules/packs/community/gosec-go/rules/G501-md5-import.yaml +21 -0
- attocode_intel/rules/packs/examples/cpp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/cpp/rules/security.yaml +90 -0
- attocode_intel/rules/packs/examples/cpp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/csharp/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/csharp/rules/security.yaml +69 -0
- attocode_intel/rules/packs/examples/csharp/rules/structural.yaml +42 -0
- attocode_intel/rules/packs/examples/go/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/go/rules/performance.yaml +136 -0
- attocode_intel/rules/packs/examples/go/rules/security.yaml +48 -0
- attocode_intel/rules/packs/examples/go/rules/structural.yaml +58 -0
- attocode_intel/rules/packs/examples/go/taint/sanitizers.yaml +24 -0
- attocode_intel/rules/packs/examples/go/taint/sinks.yaml +51 -0
- attocode_intel/rules/packs/examples/go/taint/sources.yaml +26 -0
- attocode_intel/rules/packs/examples/java/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/java/rules/correctness.yaml +55 -0
- attocode_intel/rules/packs/examples/java/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/examples/java/taint/sinks.yaml +37 -0
- attocode_intel/rules/packs/examples/java/taint/sources.yaml +21 -0
- attocode_intel/rules/packs/examples/kotlin/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/kotlin/rules/correctness.yaml +50 -0
- attocode_intel/rules/packs/examples/kotlin/rules/structural.yaml +38 -0
- attocode_intel/rules/packs/examples/php/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/php/rules/security.yaml +66 -0
- attocode_intel/rules/packs/examples/php/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/python/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/python/rules/performance.yaml +90 -0
- attocode_intel/rules/packs/examples/python/rules/structural.yaml +55 -0
- attocode_intel/rules/packs/examples/ruby/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/ruby/rules/security.yaml +73 -0
- attocode_intel/rules/packs/examples/ruby/rules/structural.yaml +41 -0
- attocode_intel/rules/packs/examples/rust/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/rust/rules/correctness.yaml +51 -0
- attocode_intel/rules/packs/examples/rust/rules/structural.yaml +49 -0
- attocode_intel/rules/packs/examples/typescript/manifest.yaml +4 -0
- attocode_intel/rules/packs/examples/typescript/rules/performance.yaml +70 -0
- attocode_intel/rules/packs/examples/typescript/rules/structural.yaml +48 -0
- attocode_intel/rules/packs/pack_loader.py +244 -0
- attocode_intel/rules/plugins/__init__.py +1 -0
- attocode_intel/rules/plugins/plugin_loader.py +132 -0
- attocode_intel/rules/profiling.py +332 -0
- attocode_intel/rules/registry.py +213 -0
- attocode_intel/rules/sarif.py +187 -0
- attocode_intel/rules/synthesis.py +420 -0
- attocode_intel/rules/testing.py +326 -0
- attocode_intel/server.py +734 -0
- attocode_intel/service.py +3416 -0
- attocode_intel/sharing.py +57 -0
- attocode_intel/storage/__init__.py +6 -0
- attocode_intel/storage/branch_overlay.py +352 -0
- attocode_intel/storage/content_store.py +220 -0
- attocode_intel/storage/dependency_store.py +143 -0
- attocode_intel/storage/diff_engine.py +187 -0
- attocode_intel/storage/embedding_store.py +594 -0
- attocode_intel/storage/provenance_store.py +137 -0
- attocode_intel/storage/security_scanner_db.py +164 -0
- attocode_intel/storage/symbol_store.py +160 -0
- attocode_intel/structural_evidence.py +144 -0
- attocode_intel/symbol_inspection.py +141 -0
- attocode_intel/symbol_links.py +167 -0
- attocode_intel/telemetry.py +42 -0
- attocode_intel/test_ranking.py +73 -0
- attocode_intel/testing/__init__.py +43 -0
- attocode_intel/testing/fixtures.py +122 -0
- attocode_intel/testing/helpers.py +75 -0
- attocode_intel/testing/mocks.py +99 -0
- attocode_intel/tools/__init__.py +2 -0
- attocode_intel/tools/adr_tools.py +628 -0
- attocode_intel/tools/analysis_tools.py +382 -0
- attocode_intel/tools/composite_tools.py +695 -0
- attocode_intel/tools/cross_mode_tools.py +173 -0
- attocode_intel/tools/dead_code_tools.py +582 -0
- attocode_intel/tools/distill_tools.py +353 -0
- attocode_intel/tools/frecency_tools.py +220 -0
- attocode_intel/tools/fuzzy_tools.py +255 -0
- attocode_intel/tools/history_tools.py +486 -0
- attocode_intel/tools/learning_tools.py +222 -0
- attocode_intel/tools/lsp_tools.py +218 -0
- attocode_intel/tools/maintenance_tools.py +1662 -0
- attocode_intel/tools/navigation_tools.py +220 -0
- attocode_intel/tools/overlay_tools.py +540 -0
- attocode_intel/tools/pin_store.py +432 -0
- attocode_intel/tools/pin_tools.py +266 -0
- attocode_intel/tools/query_constraints_tools.py +262 -0
- attocode_intel/tools/query_history_tools.py +158 -0
- attocode_intel/tools/readiness_tools.py +44 -0
- attocode_intel/tools/rule_tools.py +992 -0
- attocode_intel/tools/search_tools.py +770 -0
- attocode_intel/tools/snapshot_tools.py +624 -0
- attocode_intel/workers/__init__.py +1 -0
- attocode_intel/workers/job_utils.py +111 -0
- attocode_intel/workers/jobs.py +573 -0
- attocode_intel/workers/run.py +30 -0
- attocode_intel/workers/settings.py +61 -0
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""SQLite-backed vector store for semantic search.
|
|
2
|
+
|
|
3
|
+
Stores embeddings as packed float32 BLOBs in SQLite. Uses numpy-accelerated
|
|
4
|
+
batch cosine similarity with in-memory caching for fast retrieval.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import struct
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
_HAS_NUMPY = True
|
|
23
|
+
except ImportError:
|
|
24
|
+
np = None # type: ignore[assignment]
|
|
25
|
+
_HAS_NUMPY = False
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Schema version for the vectors DB. Bump this when adding columns so the
|
|
30
|
+
# store detects upgrades automatically and runs the appropriate ALTER
|
|
31
|
+
# scripts at startup.
|
|
32
|
+
VECTOR_STORE_SCHEMA_VERSION = "2"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VectorStoreDimensionMismatchError(RuntimeError):
|
|
36
|
+
"""Raised when the running embedding provider's dimension does not match
|
|
37
|
+
the vectors already persisted on disk.
|
|
38
|
+
|
|
39
|
+
The old behavior in this store was to silently DELETE every row on
|
|
40
|
+
dim mismatch. That was the local-side sibling of the server-side
|
|
41
|
+
``UPDATE embeddings SET vector = NULL`` footgun — quietly destroying
|
|
42
|
+
user data because the system couldn't figure out the right thing to do.
|
|
43
|
+
|
|
44
|
+
Now: loud error. Caller (``SemanticSearchManager``) catches it once,
|
|
45
|
+
logs a visible warning, and puts the store into a degraded read-only
|
|
46
|
+
mode until the user resolves it (via ``clear_embeddings`` once that
|
|
47
|
+
tool lands in phase 2, or by picking a provider that matches).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, *, stored: int, expected: int, db_path: str) -> None:
|
|
51
|
+
self.stored = stored
|
|
52
|
+
self.expected = expected
|
|
53
|
+
self.db_path = db_path
|
|
54
|
+
super().__init__(
|
|
55
|
+
f"vector store at {db_path} has stored dim {stored} but provider "
|
|
56
|
+
f"expects dim {expected}. Refusing to wipe the existing vectors. "
|
|
57
|
+
f"Resolve by choosing a provider with matching dim, or (phase 2) "
|
|
58
|
+
f"run embeddings_rotate_model() or clear_embeddings(confirm=True)."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class VectorStoreRotationActiveError(RuntimeError):
|
|
63
|
+
"""Raised when a destructive write hits the vectors table while an
|
|
64
|
+
embedding rotation is still active.
|
|
65
|
+
|
|
66
|
+
Mid-rotation writes to the primary ``vectors`` table would be lost
|
|
67
|
+
after cutover. Any destructive write during ``pending``,
|
|
68
|
+
``backfilling``, ``ready_to_cutover``, or ``cutover_done`` raises
|
|
69
|
+
rather than silently corrupting the rotation source data.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, *, rotation_state: str, db_path: str) -> None:
|
|
73
|
+
self.rotation_state = rotation_state
|
|
74
|
+
self.db_path = db_path
|
|
75
|
+
super().__init__(
|
|
76
|
+
f"vector store at {db_path} has an active rotation "
|
|
77
|
+
f"(state={rotation_state}). Refusing destructive writes "
|
|
78
|
+
f"until the rotation completes or is aborted. Use "
|
|
79
|
+
f"embeddings_rotate_abort() to cancel, or wait for "
|
|
80
|
+
f"embeddings_rotate_cutover() + embeddings_rotate_gc_old()."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True)
|
|
85
|
+
class VectorEntry:
|
|
86
|
+
"""A stored vector with metadata."""
|
|
87
|
+
|
|
88
|
+
id: str
|
|
89
|
+
file_path: str
|
|
90
|
+
chunk_type: str # "file", "function", "class"
|
|
91
|
+
name: str
|
|
92
|
+
text: str
|
|
93
|
+
vector: list[float]
|
|
94
|
+
# Provenance — optional; empty defaults keep callers that don't care
|
|
95
|
+
# working. New indexers should fill these in.
|
|
96
|
+
blob_oid: str = ""
|
|
97
|
+
action_hash: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(slots=True)
|
|
101
|
+
class SearchResult:
|
|
102
|
+
"""A search result with similarity score."""
|
|
103
|
+
|
|
104
|
+
id: str
|
|
105
|
+
file_path: str
|
|
106
|
+
chunk_type: str
|
|
107
|
+
name: str
|
|
108
|
+
text: str
|
|
109
|
+
score: float
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pack_vector(vec: list[float]) -> bytes:
|
|
113
|
+
"""Pack a float vector into bytes."""
|
|
114
|
+
return struct.pack(f"{len(vec)}f", *vec)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _unpack_vector(data: bytes, dim: int) -> list[float]:
|
|
118
|
+
"""Unpack bytes into a float vector."""
|
|
119
|
+
return list(struct.unpack(f"{dim}f", data))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
123
|
+
"""Compute cosine similarity between two vectors (pure Python fallback)."""
|
|
124
|
+
if len(a) != len(b) or not a:
|
|
125
|
+
return 0.0
|
|
126
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
127
|
+
norm_a = math.sqrt(sum(x * x for x in a))
|
|
128
|
+
norm_b = math.sqrt(sum(x * x for x in b))
|
|
129
|
+
if norm_a == 0.0 or norm_b == 0.0:
|
|
130
|
+
return 0.0
|
|
131
|
+
return dot / (norm_a * norm_b)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _batch_cosine_similarity(
|
|
135
|
+
query: np.ndarray,
|
|
136
|
+
matrix: np.ndarray,
|
|
137
|
+
) -> np.ndarray:
|
|
138
|
+
"""Vectorized cosine similarity: query (D,) vs matrix (N, D) → scores (N,).
|
|
139
|
+
|
|
140
|
+
Uses numpy BLAS for a single matrix-vector multiply instead of N Python loops.
|
|
141
|
+
"""
|
|
142
|
+
query_norm = np.linalg.norm(query)
|
|
143
|
+
if query_norm == 0.0 or len(matrix) == 0:
|
|
144
|
+
return np.zeros(len(matrix), dtype=np.float32)
|
|
145
|
+
|
|
146
|
+
query_normed = query / query_norm
|
|
147
|
+
norms = np.linalg.norm(matrix, axis=1)
|
|
148
|
+
|
|
149
|
+
scores = np.zeros(len(matrix), dtype=np.float32)
|
|
150
|
+
mask = norms > 0
|
|
151
|
+
scores[mask] = (matrix[mask] @ query_normed) / norms[mask]
|
|
152
|
+
return scores
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(slots=True)
|
|
156
|
+
class VectorStore:
|
|
157
|
+
"""SQLite-backed vector store with numpy-accelerated search.
|
|
158
|
+
|
|
159
|
+
Usage::
|
|
160
|
+
|
|
161
|
+
store = VectorStore(db_path="vectors.db", dimension=384)
|
|
162
|
+
store.upsert(VectorEntry(id="f1", ...))
|
|
163
|
+
results = store.search(query_vector, top_k=10)
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
db_path: str
|
|
167
|
+
dimension: int
|
|
168
|
+
# Optional provenance for new inserts. If set, every upsert records these
|
|
169
|
+
# fields into the vectors row so we can later answer "which model made
|
|
170
|
+
# this?" and detect silent drift.
|
|
171
|
+
model_name: str = ""
|
|
172
|
+
model_version: str = ""
|
|
173
|
+
# When True, a dimension mismatch puts the store in degraded read-only
|
|
174
|
+
# mode instead of raising. Used by callers that want to show a nice
|
|
175
|
+
# error in the UI rather than crash on import.
|
|
176
|
+
strict_dimension: bool = True
|
|
177
|
+
# Set by _validate_dimension on mismatch so callers can detect degraded
|
|
178
|
+
# mode without catching the exception.
|
|
179
|
+
degraded: bool = False
|
|
180
|
+
degraded_reason: str = ""
|
|
181
|
+
_conn: sqlite3.Connection | None = field(default=None, repr=False)
|
|
182
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
183
|
+
# In-memory vector cache for numpy batch search
|
|
184
|
+
_vec_matrix: Any = field(default=None, repr=False) # np.ndarray (N, D)
|
|
185
|
+
_vec_meta: list[tuple] | None = field(default=None, repr=False)
|
|
186
|
+
_vec_cache_version: int = field(default=0, repr=False)
|
|
187
|
+
_vec_loaded_version: int = field(default=-1, repr=False)
|
|
188
|
+
# Codex M5: last observed value of store_metadata._rotator_cache_ver.
|
|
189
|
+
# Checked before each search so a cutover in another process
|
|
190
|
+
# correctly invalidates this open store's in-memory cache.
|
|
191
|
+
_last_external_cache_ver: int = field(default=0, repr=False)
|
|
192
|
+
|
|
193
|
+
def __post_init__(self) -> None:
|
|
194
|
+
# Ensure directory exists
|
|
195
|
+
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
|
|
196
|
+
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
|
197
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
198
|
+
self._create_tables()
|
|
199
|
+
self._migrate_schema()
|
|
200
|
+
self._validate_dimension()
|
|
201
|
+
|
|
202
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
203
|
+
"""Return the active connection or raise if closed."""
|
|
204
|
+
if self._conn is None:
|
|
205
|
+
raise RuntimeError("VectorStore connection is closed")
|
|
206
|
+
return self._conn
|
|
207
|
+
|
|
208
|
+
def _create_tables(self) -> None:
|
|
209
|
+
conn = self._get_conn()
|
|
210
|
+
# Schema v2 — includes provenance columns. Pre-v2 stores have the
|
|
211
|
+
# same base shape and are migrated in _migrate_schema().
|
|
212
|
+
conn.execute("""
|
|
213
|
+
CREATE TABLE IF NOT EXISTS vectors (
|
|
214
|
+
id TEXT PRIMARY KEY,
|
|
215
|
+
file_path TEXT NOT NULL,
|
|
216
|
+
chunk_type TEXT NOT NULL,
|
|
217
|
+
name TEXT NOT NULL,
|
|
218
|
+
text TEXT NOT NULL,
|
|
219
|
+
vector BLOB NOT NULL,
|
|
220
|
+
model_name TEXT NOT NULL DEFAULT '',
|
|
221
|
+
model_version TEXT NOT NULL DEFAULT '',
|
|
222
|
+
dimension INTEGER NOT NULL DEFAULT 0,
|
|
223
|
+
produced_at REAL NOT NULL DEFAULT 0,
|
|
224
|
+
blob_oid TEXT NOT NULL DEFAULT '',
|
|
225
|
+
action_hash TEXT NOT NULL DEFAULT ''
|
|
226
|
+
)
|
|
227
|
+
""")
|
|
228
|
+
conn.execute(
|
|
229
|
+
"CREATE INDEX IF NOT EXISTS idx_vectors_file ON vectors(file_path)"
|
|
230
|
+
)
|
|
231
|
+
# The model/blob indexes reference columns that a pre-v2 table may not
|
|
232
|
+
# have yet — they're created in ``_migrate_schema`` after the columns
|
|
233
|
+
# are guaranteed to exist.
|
|
234
|
+
# Metadata table for tracking index freshness per file
|
|
235
|
+
conn.execute("""
|
|
236
|
+
CREATE TABLE IF NOT EXISTS file_metadata (
|
|
237
|
+
file_path TEXT PRIMARY KEY,
|
|
238
|
+
last_indexed_at REAL NOT NULL,
|
|
239
|
+
file_mtime REAL NOT NULL,
|
|
240
|
+
chunk_count INTEGER NOT NULL DEFAULT 0
|
|
241
|
+
)
|
|
242
|
+
""")
|
|
243
|
+
# Store-level metadata (dimension, schema_version, active model, etc.)
|
|
244
|
+
conn.execute("""
|
|
245
|
+
CREATE TABLE IF NOT EXISTS store_metadata (
|
|
246
|
+
key TEXT PRIMARY KEY,
|
|
247
|
+
value TEXT NOT NULL
|
|
248
|
+
)
|
|
249
|
+
""")
|
|
250
|
+
conn.commit()
|
|
251
|
+
|
|
252
|
+
def _migrate_schema(self) -> None:
|
|
253
|
+
"""Additively migrate an older vectors table to the current schema.
|
|
254
|
+
|
|
255
|
+
Pre-v2 stores lack the provenance columns (``model_name``,
|
|
256
|
+
``model_version``, ``dimension``, ``produced_at``, ``blob_oid``,
|
|
257
|
+
``action_hash``). We ADD COLUMN each missing one with a safe default;
|
|
258
|
+
existing rows get a 'legacy' marker so later tools can identify
|
|
259
|
+
them for re-indexing.
|
|
260
|
+
|
|
261
|
+
Idempotent: running on a fresh v2 store is a no-op.
|
|
262
|
+
"""
|
|
263
|
+
conn = self._get_conn()
|
|
264
|
+
columns = {
|
|
265
|
+
row[1]
|
|
266
|
+
for row in conn.execute("PRAGMA table_info(vectors)").fetchall()
|
|
267
|
+
}
|
|
268
|
+
added: list[str] = []
|
|
269
|
+
additive_columns = [
|
|
270
|
+
("model_name", "TEXT NOT NULL DEFAULT ''"),
|
|
271
|
+
("model_version", "TEXT NOT NULL DEFAULT ''"),
|
|
272
|
+
("dimension", "INTEGER NOT NULL DEFAULT 0"),
|
|
273
|
+
("produced_at", "REAL NOT NULL DEFAULT 0"),
|
|
274
|
+
("blob_oid", "TEXT NOT NULL DEFAULT ''"),
|
|
275
|
+
("action_hash", "TEXT NOT NULL DEFAULT ''"),
|
|
276
|
+
]
|
|
277
|
+
for col, ddl in additive_columns:
|
|
278
|
+
if col not in columns:
|
|
279
|
+
conn.execute(f"ALTER TABLE vectors ADD COLUMN {col} {ddl}")
|
|
280
|
+
added.append(col)
|
|
281
|
+
|
|
282
|
+
if added:
|
|
283
|
+
# Mark pre-v2 rows so callers can find and re-index them later.
|
|
284
|
+
conn.execute(
|
|
285
|
+
"UPDATE vectors SET model_name = 'legacy-pre-v2' "
|
|
286
|
+
"WHERE model_name = '' AND length(vector) > 0"
|
|
287
|
+
)
|
|
288
|
+
logger.info(
|
|
289
|
+
"vector_store: migrated v1 schema -> v2 (added columns: %s)",
|
|
290
|
+
", ".join(added),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Ensure indexes exist on newly-added columns.
|
|
294
|
+
conn.execute(
|
|
295
|
+
"CREATE INDEX IF NOT EXISTS idx_vectors_model ON vectors(model_name, model_version)"
|
|
296
|
+
)
|
|
297
|
+
conn.execute(
|
|
298
|
+
"CREATE INDEX IF NOT EXISTS idx_vectors_blob ON vectors(blob_oid)"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Record schema version.
|
|
302
|
+
conn.execute(
|
|
303
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES ('schema_version', ?)",
|
|
304
|
+
(VECTOR_STORE_SCHEMA_VERSION,),
|
|
305
|
+
)
|
|
306
|
+
conn.commit()
|
|
307
|
+
|
|
308
|
+
def _validate_dimension(self) -> None:
|
|
309
|
+
"""Check stored dimension matches current.
|
|
310
|
+
|
|
311
|
+
Behavior change vs the old code: a mismatch NO LONGER silently wipes
|
|
312
|
+
``vectors`` + ``file_metadata``. Instead:
|
|
313
|
+
|
|
314
|
+
- If ``strict_dimension`` is True (the default): raise
|
|
315
|
+
:class:`VectorStoreDimensionMismatchError`. The caller is expected
|
|
316
|
+
to handle this by switching providers, running a rotation, or
|
|
317
|
+
invoking the (phase-2) ``clear_embeddings`` tool.
|
|
318
|
+
- If ``strict_dimension`` is False: put the store into degraded
|
|
319
|
+
read-only mode by setting ``self.degraded`` and
|
|
320
|
+
``self.degraded_reason``, and bail out of the constructor. Writes
|
|
321
|
+
then refuse to land.
|
|
322
|
+
|
|
323
|
+
Why change: silent wipe meant every `attocode` invocation with a
|
|
324
|
+
different env-var / model flag deleted the user's embedding index
|
|
325
|
+
without ever telling them. That's the exact footgun this whole
|
|
326
|
+
work item is fixing.
|
|
327
|
+
"""
|
|
328
|
+
conn = self._get_conn()
|
|
329
|
+
row = conn.execute(
|
|
330
|
+
"SELECT value FROM store_metadata WHERE key = 'dimension'"
|
|
331
|
+
).fetchone()
|
|
332
|
+
if row:
|
|
333
|
+
stored_dim = int(row[0])
|
|
334
|
+
if stored_dim != self.dimension:
|
|
335
|
+
# Non-destructive: refuse to proceed.
|
|
336
|
+
msg = (
|
|
337
|
+
f"embedding dimension mismatch (stored={stored_dim}, "
|
|
338
|
+
f"expected={self.dimension})"
|
|
339
|
+
)
|
|
340
|
+
if self.strict_dimension:
|
|
341
|
+
logger.error(
|
|
342
|
+
"%s; refusing to wipe vectors. Use embeddings_rotate_model "
|
|
343
|
+
"or clear_embeddings(confirm=true) to resolve.", msg,
|
|
344
|
+
)
|
|
345
|
+
raise VectorStoreDimensionMismatchError(
|
|
346
|
+
stored=stored_dim,
|
|
347
|
+
expected=self.dimension,
|
|
348
|
+
db_path=self.db_path,
|
|
349
|
+
)
|
|
350
|
+
logger.warning(
|
|
351
|
+
"%s; store is in DEGRADED read-only mode. "
|
|
352
|
+
"Existing vectors preserved.", msg,
|
|
353
|
+
)
|
|
354
|
+
self.degraded = True
|
|
355
|
+
self.degraded_reason = "dimension_mismatch"
|
|
356
|
+
return
|
|
357
|
+
else:
|
|
358
|
+
conn.execute(
|
|
359
|
+
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES ('dimension', ?)",
|
|
360
|
+
(str(self.dimension),),
|
|
361
|
+
)
|
|
362
|
+
conn.commit()
|
|
363
|
+
|
|
364
|
+
def _guard_writes(self) -> None:
|
|
365
|
+
"""Refuse writes when the store is in degraded mode."""
|
|
366
|
+
if self.degraded:
|
|
367
|
+
raise VectorStoreDimensionMismatchError(
|
|
368
|
+
stored=0,
|
|
369
|
+
expected=self.dimension,
|
|
370
|
+
db_path=self.db_path,
|
|
371
|
+
)
|
|
372
|
+
self._check_rotation_lock()
|
|
373
|
+
|
|
374
|
+
def _check_rotation_lock(self) -> None:
|
|
375
|
+
"""Reject destructive writes while a rotation is mid-flight.
|
|
376
|
+
|
|
377
|
+
``clear_*`` / ``upsert*`` must not run while an embedding rotation
|
|
378
|
+
is still reading from the primary ``vectors`` table. Reads
|
|
379
|
+
``store_metadata.rotation_state`` and raises
|
|
380
|
+
:class:`VectorStoreRotationActiveError` for active states.
|
|
381
|
+
Pre-rotation (``none``), aborted, failed, and completed-and-GCed
|
|
382
|
+
states pass through.
|
|
383
|
+
"""
|
|
384
|
+
conn = self._get_conn()
|
|
385
|
+
try:
|
|
386
|
+
row = conn.execute(
|
|
387
|
+
"SELECT value FROM store_metadata WHERE key = 'rotation_state'"
|
|
388
|
+
).fetchone()
|
|
389
|
+
except sqlite3.OperationalError:
|
|
390
|
+
# Pre-v2 store without a store_metadata row yet — treat as
|
|
391
|
+
# no rotation.
|
|
392
|
+
return
|
|
393
|
+
if row is None:
|
|
394
|
+
return
|
|
395
|
+
state = str(row[0])
|
|
396
|
+
active_states = {
|
|
397
|
+
"pending", "backfilling", "ready_to_cutover", "cutover_done",
|
|
398
|
+
}
|
|
399
|
+
if state in active_states:
|
|
400
|
+
raise VectorStoreRotationActiveError(
|
|
401
|
+
rotation_state=state, db_path=self.db_path,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def _check_external_cache_bump(self) -> None:
|
|
405
|
+
"""Pick up cache invalidations from other processes.
|
|
406
|
+
|
|
407
|
+
``EmbeddingRotator.cutover()`` writes ``_rotator_cache_ver`` into
|
|
408
|
+
``store_metadata`` to signal already-open ``VectorStore`` instances
|
|
409
|
+
to reload their in-memory numpy matrix. We compare the stored
|
|
410
|
+
value against the last seen and bump the local
|
|
411
|
+
``_vec_cache_version`` on drift so the next search triggers a
|
|
412
|
+
reload.
|
|
413
|
+
"""
|
|
414
|
+
conn = self._get_conn()
|
|
415
|
+
try:
|
|
416
|
+
row = conn.execute(
|
|
417
|
+
"SELECT value FROM store_metadata WHERE key = '_rotator_cache_ver'"
|
|
418
|
+
).fetchone()
|
|
419
|
+
except sqlite3.OperationalError:
|
|
420
|
+
return
|
|
421
|
+
if row is None:
|
|
422
|
+
return
|
|
423
|
+
try:
|
|
424
|
+
external = int(row[0])
|
|
425
|
+
except (TypeError, ValueError):
|
|
426
|
+
return
|
|
427
|
+
if external > self._last_external_cache_ver:
|
|
428
|
+
self._last_external_cache_ver = external
|
|
429
|
+
# Bump the local version so _search_numpy reloads.
|
|
430
|
+
self._vec_cache_version += 1
|
|
431
|
+
|
|
432
|
+
def upsert(self, entry: VectorEntry) -> None:
|
|
433
|
+
"""Insert or update a vector entry."""
|
|
434
|
+
self._guard_writes()
|
|
435
|
+
conn = self._get_conn()
|
|
436
|
+
packed = _pack_vector(entry.vector)
|
|
437
|
+
now = time.time()
|
|
438
|
+
with self._lock:
|
|
439
|
+
conn.execute(
|
|
440
|
+
"""INSERT OR REPLACE INTO vectors
|
|
441
|
+
(id, file_path, chunk_type, name, text, vector,
|
|
442
|
+
model_name, model_version, dimension, produced_at,
|
|
443
|
+
blob_oid, action_hash)
|
|
444
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
445
|
+
(
|
|
446
|
+
entry.id, entry.file_path, entry.chunk_type, entry.name,
|
|
447
|
+
entry.text, packed,
|
|
448
|
+
self.model_name, self.model_version, self.dimension,
|
|
449
|
+
now, entry.blob_oid, entry.action_hash,
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
conn.commit()
|
|
453
|
+
self._vec_cache_version += 1
|
|
454
|
+
|
|
455
|
+
def upsert_batch(self, entries: list[VectorEntry]) -> None:
|
|
456
|
+
"""Batch insert/update vector entries."""
|
|
457
|
+
self._guard_writes()
|
|
458
|
+
conn = self._get_conn()
|
|
459
|
+
now = time.time()
|
|
460
|
+
rows = [
|
|
461
|
+
(
|
|
462
|
+
e.id, e.file_path, e.chunk_type, e.name, e.text,
|
|
463
|
+
_pack_vector(e.vector),
|
|
464
|
+
self.model_name, self.model_version, self.dimension,
|
|
465
|
+
now, e.blob_oid, e.action_hash,
|
|
466
|
+
)
|
|
467
|
+
for e in entries
|
|
468
|
+
]
|
|
469
|
+
with self._lock:
|
|
470
|
+
conn.executemany(
|
|
471
|
+
"""INSERT OR REPLACE INTO vectors
|
|
472
|
+
(id, file_path, chunk_type, name, text, vector,
|
|
473
|
+
model_name, model_version, dimension, produced_at,
|
|
474
|
+
blob_oid, action_hash)
|
|
475
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
476
|
+
rows,
|
|
477
|
+
)
|
|
478
|
+
conn.commit()
|
|
479
|
+
self._vec_cache_version += 1
|
|
480
|
+
|
|
481
|
+
def delete_by_file(self, file_path: str) -> int:
|
|
482
|
+
"""Delete all entries for a file. Returns count deleted."""
|
|
483
|
+
self._guard_writes()
|
|
484
|
+
conn = self._get_conn()
|
|
485
|
+
with self._lock:
|
|
486
|
+
cursor = conn.execute(
|
|
487
|
+
"DELETE FROM vectors WHERE file_path = ?", (file_path,),
|
|
488
|
+
)
|
|
489
|
+
conn.commit()
|
|
490
|
+
self._vec_cache_version += 1
|
|
491
|
+
return cursor.rowcount
|
|
492
|
+
|
|
493
|
+
def clear_all(self) -> int:
|
|
494
|
+
"""Delete every vector + file-metadata row. Returns count deleted.
|
|
495
|
+
|
|
496
|
+
Preserves the ``store_metadata`` table (schema version + stored
|
|
497
|
+
dimension) so a subsequent reopen doesn't trip the dim-mismatch
|
|
498
|
+
check. That's the whole point of clear_all vs ``rm -f embeddings.db``:
|
|
499
|
+
it's the safe reset path.
|
|
500
|
+
|
|
501
|
+
Codex B3: refuses to run during an active rotation.
|
|
502
|
+
"""
|
|
503
|
+
self._guard_writes()
|
|
504
|
+
conn = self._get_conn()
|
|
505
|
+
with self._lock:
|
|
506
|
+
cursor = conn.execute("DELETE FROM vectors")
|
|
507
|
+
conn.execute("DELETE FROM file_metadata")
|
|
508
|
+
conn.commit()
|
|
509
|
+
self._vec_cache_version += 1
|
|
510
|
+
return cursor.rowcount
|
|
511
|
+
|
|
512
|
+
def clear_by_model(self, model_name: str, model_version: str = "") -> int:
|
|
513
|
+
"""Delete only vectors for a given model (+ optional version).
|
|
514
|
+
|
|
515
|
+
Returns count deleted. Used during embedding rotation's GC phase —
|
|
516
|
+
drops old-model rows once the new model is live.
|
|
517
|
+
|
|
518
|
+
Codex B3: refuses to run during an active rotation.
|
|
519
|
+
"""
|
|
520
|
+
self._guard_writes()
|
|
521
|
+
conn = self._get_conn()
|
|
522
|
+
with self._lock:
|
|
523
|
+
if model_version:
|
|
524
|
+
cursor = conn.execute(
|
|
525
|
+
"DELETE FROM vectors WHERE model_name = ? AND model_version = ?",
|
|
526
|
+
(model_name, model_version),
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
cursor = conn.execute(
|
|
530
|
+
"DELETE FROM vectors WHERE model_name = ?",
|
|
531
|
+
(model_name,),
|
|
532
|
+
)
|
|
533
|
+
conn.commit()
|
|
534
|
+
self._vec_cache_version += 1
|
|
535
|
+
return cursor.rowcount
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------------
|
|
538
|
+
# In-memory vector cache for numpy batch search
|
|
539
|
+
# ------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def _load_vector_cache(self) -> None:
|
|
542
|
+
"""Load all vectors from SQLite into a numpy matrix."""
|
|
543
|
+
conn = self._get_conn()
|
|
544
|
+
with self._lock:
|
|
545
|
+
rows = conn.execute(
|
|
546
|
+
"SELECT id, file_path, chunk_type, name, text, vector FROM vectors",
|
|
547
|
+
).fetchall()
|
|
548
|
+
|
|
549
|
+
if not rows:
|
|
550
|
+
self._vec_matrix = np.empty((0, self.dimension), dtype=np.float32)
|
|
551
|
+
self._vec_meta = []
|
|
552
|
+
self._vec_loaded_version = self._vec_cache_version
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
meta: list[tuple] = []
|
|
556
|
+
vecs: list[np.ndarray] = []
|
|
557
|
+
for row in rows:
|
|
558
|
+
try:
|
|
559
|
+
v = np.frombuffer(row[5], dtype=np.float32)
|
|
560
|
+
if len(v) == self.dimension:
|
|
561
|
+
vecs.append(v)
|
|
562
|
+
meta.append((row[0], row[1], row[2], row[3], row[4]))
|
|
563
|
+
except (ValueError, struct.error):
|
|
564
|
+
logger.warning("Skipping corrupt vector row id=%s", row[0])
|
|
565
|
+
|
|
566
|
+
if vecs:
|
|
567
|
+
self._vec_matrix = np.vstack(vecs)
|
|
568
|
+
else:
|
|
569
|
+
self._vec_matrix = np.empty((0, self.dimension), dtype=np.float32)
|
|
570
|
+
self._vec_meta = meta
|
|
571
|
+
self._vec_loaded_version = self._vec_cache_version
|
|
572
|
+
logger.debug(
|
|
573
|
+
"Vector cache loaded: %d vectors (%d dim)",
|
|
574
|
+
len(meta), self.dimension,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
# Search
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
def search(
|
|
582
|
+
self,
|
|
583
|
+
query_vector: list[float],
|
|
584
|
+
top_k: int = 10,
|
|
585
|
+
file_filter: str = "",
|
|
586
|
+
existing_files: set[str] | None = None,
|
|
587
|
+
) -> list[SearchResult]:
|
|
588
|
+
"""Search for similar vectors.
|
|
589
|
+
|
|
590
|
+
Uses numpy batch cosine similarity when available (100-1000x faster
|
|
591
|
+
than pure Python), with automatic in-memory caching of the vector
|
|
592
|
+
matrix. Falls back to per-vector Python loop if numpy is unavailable.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
query_vector: Query embedding vector.
|
|
596
|
+
top_k: Number of results to return.
|
|
597
|
+
file_filter: Optional glob pattern to filter files (e.g. "*.py").
|
|
598
|
+
existing_files: Optional set of file paths that exist on disk.
|
|
599
|
+
When provided, vectors whose file_path is not in this set
|
|
600
|
+
are skipped (filters out stale branch data in local mode).
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Top-k results sorted by similarity score (highest first).
|
|
604
|
+
"""
|
|
605
|
+
if not query_vector:
|
|
606
|
+
return []
|
|
607
|
+
|
|
608
|
+
# Pick up any cache invalidation signal written by another
|
|
609
|
+
# process (e.g. EmbeddingRotator.cutover in a separate MCP
|
|
610
|
+
# worker) before consulting the in-memory cache.
|
|
611
|
+
self._check_external_cache_bump()
|
|
612
|
+
|
|
613
|
+
if _HAS_NUMPY:
|
|
614
|
+
return self._search_numpy(query_vector, top_k, file_filter, existing_files)
|
|
615
|
+
return self._search_python(query_vector, top_k, file_filter, existing_files)
|
|
616
|
+
|
|
617
|
+
def _search_numpy(
|
|
618
|
+
self,
|
|
619
|
+
query_vector: list[float],
|
|
620
|
+
top_k: int,
|
|
621
|
+
file_filter: str,
|
|
622
|
+
existing_files: set[str] | None,
|
|
623
|
+
) -> list[SearchResult]:
|
|
624
|
+
"""Numpy-accelerated vector search with in-memory cache."""
|
|
625
|
+
# Ensure cache is current
|
|
626
|
+
if self._vec_loaded_version != self._vec_cache_version or self._vec_matrix is None:
|
|
627
|
+
self._load_vector_cache()
|
|
628
|
+
|
|
629
|
+
if self._vec_matrix is None or len(self._vec_matrix) == 0:
|
|
630
|
+
return []
|
|
631
|
+
|
|
632
|
+
query_np = np.array(query_vector, dtype=np.float32)
|
|
633
|
+
|
|
634
|
+
# Batch cosine similarity — single BLAS matmul
|
|
635
|
+
scores = _batch_cosine_similarity(query_np, self._vec_matrix)
|
|
636
|
+
|
|
637
|
+
# Apply filters by zeroing out excluded entries
|
|
638
|
+
if file_filter or existing_files is not None:
|
|
639
|
+
import fnmatch
|
|
640
|
+
|
|
641
|
+
for i, meta in enumerate(self._vec_meta):
|
|
642
|
+
fp = meta[1] # file_path
|
|
643
|
+
if existing_files is not None and fp not in existing_files: # noqa: SIM114 — clearer as two branches than one combined or
|
|
644
|
+
scores[i] = -1.0
|
|
645
|
+
elif file_filter and not fnmatch.fnmatch(fp, file_filter):
|
|
646
|
+
scores[i] = -1.0
|
|
647
|
+
|
|
648
|
+
# Top-k selection: argpartition is O(N) vs O(N log N) for full sort
|
|
649
|
+
n = len(scores)
|
|
650
|
+
if n <= top_k:
|
|
651
|
+
top_indices = np.argsort(scores)[::-1]
|
|
652
|
+
else:
|
|
653
|
+
top_indices = np.argpartition(scores, -top_k)[-top_k:]
|
|
654
|
+
top_indices = top_indices[np.argsort(scores[top_indices])[::-1]]
|
|
655
|
+
|
|
656
|
+
results: list[SearchResult] = []
|
|
657
|
+
for idx in top_indices:
|
|
658
|
+
s = float(scores[idx])
|
|
659
|
+
if s <= 0:
|
|
660
|
+
break
|
|
661
|
+
m = self._vec_meta[idx]
|
|
662
|
+
results.append(SearchResult(
|
|
663
|
+
id=m[0], file_path=m[1], chunk_type=m[2],
|
|
664
|
+
name=m[3], text=m[4], score=round(s, 4),
|
|
665
|
+
))
|
|
666
|
+
return results
|
|
667
|
+
|
|
668
|
+
def _search_python(
|
|
669
|
+
self,
|
|
670
|
+
query_vector: list[float],
|
|
671
|
+
top_k: int,
|
|
672
|
+
file_filter: str,
|
|
673
|
+
existing_files: set[str] | None,
|
|
674
|
+
) -> list[SearchResult]:
|
|
675
|
+
"""Pure Python fallback when numpy is unavailable."""
|
|
676
|
+
conn = self._get_conn()
|
|
677
|
+
|
|
678
|
+
if file_filter:
|
|
679
|
+
import fnmatch
|
|
680
|
+
|
|
681
|
+
def _file_filter_fn(fp: str) -> bool:
|
|
682
|
+
return fnmatch.fnmatch(fp, file_filter)
|
|
683
|
+
else:
|
|
684
|
+
_file_filter_fn = None
|
|
685
|
+
|
|
686
|
+
with self._lock:
|
|
687
|
+
rows = conn.execute(
|
|
688
|
+
"SELECT id, file_path, chunk_type, name, text, vector FROM vectors",
|
|
689
|
+
).fetchall()
|
|
690
|
+
|
|
691
|
+
results: list[SearchResult] = []
|
|
692
|
+
for row in rows:
|
|
693
|
+
if existing_files is not None and row[1] not in existing_files:
|
|
694
|
+
continue
|
|
695
|
+
if _file_filter_fn and not _file_filter_fn(row[1]):
|
|
696
|
+
continue
|
|
697
|
+
try:
|
|
698
|
+
vec = _unpack_vector(row[5], self.dimension)
|
|
699
|
+
except struct.error:
|
|
700
|
+
logger.warning("Skipping corrupt vector row id=%s", row[0])
|
|
701
|
+
continue
|
|
702
|
+
score = _cosine_similarity(query_vector, vec)
|
|
703
|
+
results.append(SearchResult(
|
|
704
|
+
id=row[0],
|
|
705
|
+
file_path=row[1],
|
|
706
|
+
chunk_type=row[2],
|
|
707
|
+
name=row[3],
|
|
708
|
+
text=row[4],
|
|
709
|
+
score=round(score, 4),
|
|
710
|
+
))
|
|
711
|
+
|
|
712
|
+
results.sort(key=lambda r: r.score, reverse=True)
|
|
713
|
+
return results[:top_k]
|
|
714
|
+
|
|
715
|
+
def count(self) -> int:
|
|
716
|
+
"""Return total number of stored vectors."""
|
|
717
|
+
conn = self._get_conn()
|
|
718
|
+
with self._lock:
|
|
719
|
+
row = conn.execute("SELECT COUNT(*) FROM vectors").fetchone()
|
|
720
|
+
return row[0] if row else 0
|
|
721
|
+
|
|
722
|
+
def set_file_metadata(
|
|
723
|
+
self, file_path: str, mtime: float, chunk_count: int,
|
|
724
|
+
) -> None:
|
|
725
|
+
"""Record when a file was last indexed and its mtime."""
|
|
726
|
+
conn = self._get_conn()
|
|
727
|
+
import time as _time
|
|
728
|
+
with self._lock:
|
|
729
|
+
conn.execute(
|
|
730
|
+
"""INSERT OR REPLACE INTO file_metadata
|
|
731
|
+
(file_path, last_indexed_at, file_mtime, chunk_count)
|
|
732
|
+
VALUES (?, ?, ?, ?)""",
|
|
733
|
+
(file_path, _time.time(), mtime, chunk_count),
|
|
734
|
+
)
|
|
735
|
+
conn.commit()
|
|
736
|
+
|
|
737
|
+
def get_file_metadata(self, file_path: str) -> dict[str, Any] | None:
|
|
738
|
+
"""Get index metadata for a file. Returns None if not indexed."""
|
|
739
|
+
conn = self._get_conn()
|
|
740
|
+
with self._lock:
|
|
741
|
+
row = conn.execute(
|
|
742
|
+
"SELECT last_indexed_at, file_mtime, chunk_count "
|
|
743
|
+
"FROM file_metadata WHERE file_path = ?",
|
|
744
|
+
(file_path,),
|
|
745
|
+
).fetchone()
|
|
746
|
+
if row:
|
|
747
|
+
return {"last_indexed_at": row[0], "file_mtime": row[1], "chunk_count": row[2]}
|
|
748
|
+
return None
|
|
749
|
+
|
|
750
|
+
def get_stale_files(self, file_mtimes: dict[str, float]) -> list[str]:
|
|
751
|
+
"""Return files whose mtime is newer than last indexed mtime.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
file_mtimes: Dict of relative_path -> current mtime.
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
List of file paths that need re-indexing.
|
|
758
|
+
"""
|
|
759
|
+
conn = self._get_conn()
|
|
760
|
+
with self._lock:
|
|
761
|
+
rows = conn.execute(
|
|
762
|
+
"SELECT file_path, file_mtime FROM file_metadata"
|
|
763
|
+
).fetchall()
|
|
764
|
+
indexed = {row[0]: row[1] for row in rows}
|
|
765
|
+
stale: list[str] = []
|
|
766
|
+
for fpath, current_mtime in file_mtimes.items():
|
|
767
|
+
stored_mtime = indexed.get(fpath)
|
|
768
|
+
if stored_mtime is None or current_mtime > stored_mtime:
|
|
769
|
+
stale.append(fpath)
|
|
770
|
+
return stale
|
|
771
|
+
|
|
772
|
+
def get_all_indexed_files(self) -> list[str]:
|
|
773
|
+
"""Return all file paths that have metadata in the store."""
|
|
774
|
+
conn = self._get_conn()
|
|
775
|
+
with self._lock:
|
|
776
|
+
rows = conn.execute("SELECT file_path FROM file_metadata").fetchall()
|
|
777
|
+
return [r[0] for r in rows]
|
|
778
|
+
|
|
779
|
+
def delete_file_metadata(self, file_path: str) -> None:
|
|
780
|
+
"""Remove metadata for a file."""
|
|
781
|
+
conn = self._get_conn()
|
|
782
|
+
with self._lock:
|
|
783
|
+
conn.execute(
|
|
784
|
+
"DELETE FROM file_metadata WHERE file_path = ?", (file_path,),
|
|
785
|
+
)
|
|
786
|
+
conn.commit()
|
|
787
|
+
|
|
788
|
+
def close(self) -> None:
|
|
789
|
+
"""Close the database connection."""
|
|
790
|
+
if self._conn:
|
|
791
|
+
self._conn.close()
|
|
792
|
+
self._conn = None
|
|
793
|
+
|
|
794
|
+
def __del__(self) -> None:
|
|
795
|
+
self.close()
|