woods 1.6.1 → 2.0.0.beta2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -4,6 +4,12 @@ require 'json'
|
|
|
4
4
|
require_relative 'vector_store'
|
|
5
5
|
|
|
6
6
|
module Woods
|
|
7
|
+
# Same conditional-define pattern used elsewhere in the gem so this
|
|
8
|
+
# file can be required in isolation (e.g. by specs that bypass the
|
|
9
|
+
# full lib/woods.rb load) without tripping NameError on the
|
|
10
|
+
# dimension-mismatch and batch-conflict raises below.
|
|
11
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
12
|
+
|
|
7
13
|
module Storage
|
|
8
14
|
module VectorStore
|
|
9
15
|
# PostgreSQL + pgvector adapter for vector storage and similarity search.
|
|
@@ -21,31 +27,41 @@ module Woods
|
|
|
21
27
|
include Interface
|
|
22
28
|
|
|
23
29
|
TABLE = 'woods_vectors'
|
|
30
|
+
TABLE_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/
|
|
31
|
+
|
|
32
|
+
attr_reader :table, :schema, :dimensions
|
|
24
33
|
|
|
25
34
|
# @param connection [Object] ActiveRecord database connection
|
|
26
35
|
# @param dimensions [Integer] Size of the embedding vectors
|
|
27
|
-
|
|
36
|
+
# @param table [String] PostgreSQL table name
|
|
37
|
+
def initialize(connection:, dimensions:, table: TABLE, schema: nil)
|
|
28
38
|
@connection = connection
|
|
29
|
-
@dimensions = dimensions
|
|
39
|
+
@dimensions = normalize_dimensions(dimensions)
|
|
40
|
+
@table = table.to_s
|
|
41
|
+
@schema = schema&.to_s
|
|
42
|
+
validate_identifier!(:table, @table, table)
|
|
43
|
+
validate_identifier!(:schema, @schema, schema) if @schema
|
|
30
44
|
end
|
|
31
45
|
|
|
32
46
|
# Create the pgvector extension, vectors table, and HNSW index.
|
|
33
47
|
#
|
|
34
48
|
# Safe to call multiple times (uses IF NOT EXISTS).
|
|
35
49
|
def ensure_schema!
|
|
36
|
-
@connection.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
@connection.transaction do
|
|
51
|
+
@connection.execute('CREATE EXTENSION IF NOT EXISTS vector')
|
|
52
|
+
@connection.execute(<<~SQL)
|
|
53
|
+
CREATE TABLE IF NOT EXISTS #{qualified_table} (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
embedding vector(#{@dimensions}),
|
|
56
|
+
metadata JSONB DEFAULT '{}',
|
|
57
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
58
|
+
)
|
|
59
|
+
SQL
|
|
60
|
+
@connection.execute(<<~SQL)
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_#{@table}_embedding_hnsw
|
|
62
|
+
ON #{qualified_table} USING hnsw (embedding vector_cosine_ops)
|
|
63
|
+
SQL
|
|
64
|
+
end
|
|
49
65
|
end
|
|
50
66
|
|
|
51
67
|
# Store or update a vector with metadata.
|
|
@@ -60,7 +76,7 @@ module Woods
|
|
|
60
76
|
entry = format_entry(id, vector, metadata)
|
|
61
77
|
|
|
62
78
|
@connection.execute(<<~SQL)
|
|
63
|
-
INSERT INTO #{
|
|
79
|
+
INSERT INTO #{qualified_table} (id, embedding, metadata, created_at)
|
|
64
80
|
VALUES #{entry}
|
|
65
81
|
ON CONFLICT (id) DO UPDATE SET
|
|
66
82
|
embedding = EXCLUDED.embedding,
|
|
@@ -71,9 +87,17 @@ module Woods
|
|
|
71
87
|
|
|
72
88
|
# Store multiple vectors in a single multi-row INSERT.
|
|
73
89
|
#
|
|
90
|
+
# Duplicate ids within one batch are collapsed to the LAST occurrence
|
|
91
|
+
# (upsert semantics) before the statement is built — a multi-row
|
|
92
|
+
# `INSERT ... ON CONFLICT (id) DO UPDATE` cannot touch the same row
|
|
93
|
+
# twice, and PostgreSQL's `PG::CardinalityViolation` names no id and
|
|
94
|
+
# lands after earlier batches have already been written (#181).
|
|
95
|
+
#
|
|
74
96
|
# @param entries [Array<Hash>] Each entry has :id, :vector, :metadata keys
|
|
75
97
|
# @raise [ArgumentError] if any entry has a non-numeric or wrong-dimension vector.
|
|
76
98
|
# Validation runs BEFORE any INSERT so partial-batch writes can't occur.
|
|
99
|
+
# @raise [Woods::Error] if PostgreSQL still reports a cardinality or
|
|
100
|
+
# uniqueness violation — re-raised naming every id in the batch.
|
|
77
101
|
def store_batch(entries)
|
|
78
102
|
return if entries.empty?
|
|
79
103
|
|
|
@@ -85,10 +109,11 @@ module Woods
|
|
|
85
109
|
validate_dimensions!(vector, index: idx) if @dimensions
|
|
86
110
|
end
|
|
87
111
|
|
|
112
|
+
entries = deduplicate_entries(entries)
|
|
88
113
|
values = entries.map { |entry| format_entry(entry[:id], entry[:vector], entry[:metadata] || {}) }
|
|
89
114
|
|
|
90
|
-
|
|
91
|
-
INSERT INTO #{
|
|
115
|
+
execute_upsert(<<~SQL, entries)
|
|
116
|
+
INSERT INTO #{qualified_table} (id, embedding, metadata, created_at)
|
|
92
117
|
VALUES #{values.join(",\n")}
|
|
93
118
|
ON CONFLICT (id) DO UPDATE SET
|
|
94
119
|
embedding = EXCLUDED.embedding,
|
|
@@ -99,19 +124,27 @@ module Woods
|
|
|
99
124
|
|
|
100
125
|
# Search for similar vectors using cosine distance.
|
|
101
126
|
#
|
|
127
|
+
# The query vector is dimension-checked before SQL runs, mirroring the
|
|
128
|
+
# upsert path: otherwise a wrong-dimension query surfaces as a
|
|
129
|
+
# server-side PG::DataException instead of the typed Woods::Error
|
|
130
|
+
# callers already handle from {#store_batch}.
|
|
131
|
+
#
|
|
102
132
|
# @param query_vector [Array<Float>] The query embedding
|
|
103
133
|
# @param limit [Integer] Maximum results to return
|
|
104
134
|
# @param filters [Hash] Metadata key-value filters
|
|
105
135
|
# @return [Array<SearchResult>] Results sorted by descending similarity
|
|
136
|
+
# @raise [Woods::Error] if the query vector's length disagrees with the
|
|
137
|
+
# configured dimension
|
|
106
138
|
# @see Interface#search
|
|
107
139
|
def search(query_vector, limit: 10, filters: {})
|
|
108
140
|
validate_vector!(query_vector)
|
|
141
|
+
validate_dimensions!(query_vector) if @dimensions
|
|
109
142
|
vector_literal = build_vector_literal(query_vector)
|
|
110
143
|
where_clause = build_where(filters)
|
|
111
144
|
|
|
112
145
|
sql = <<~SQL
|
|
113
146
|
SELECT id, embedding <=> '#{vector_literal}' AS distance, metadata
|
|
114
|
-
FROM #{
|
|
147
|
+
FROM #{qualified_table}
|
|
115
148
|
#{where_clause}
|
|
116
149
|
ORDER BY distance ASC
|
|
117
150
|
LIMIT #{limit.to_i}
|
|
@@ -121,26 +154,151 @@ module Woods
|
|
|
121
154
|
rows.map { |row| row_to_result(row) }
|
|
122
155
|
end
|
|
123
156
|
|
|
157
|
+
# The vector width the table was actually created with.
|
|
158
|
+
#
|
|
159
|
+
# `ensure_schema!` is `CREATE TABLE IF NOT EXISTS`, so a table built by
|
|
160
|
+
# an earlier run at a different width is left exactly as it was — and
|
|
161
|
+
# the mismatch only surfaces later, as a PG::DataException on the first
|
|
162
|
+
# insert ("expected 384 dimensions, not 768"), after the run has already
|
|
163
|
+
# paid to embed everything. Reading it back lets the pipeline refuse up
|
|
164
|
+
# front with a re-index remedy (#214).
|
|
165
|
+
#
|
|
166
|
+
# For pgvector's `vector` type, `atttypmod` carries the dimension
|
|
167
|
+
# directly (unlike varchar, which offsets it).
|
|
168
|
+
#
|
|
169
|
+
# @return [Integer, nil] the column's dimension, or nil when the table
|
|
170
|
+
# does not exist yet or the width cannot be determined
|
|
171
|
+
def stored_dimensions
|
|
172
|
+
rows = @connection.execute(<<~SQL)
|
|
173
|
+
SELECT a.atttypmod AS dimension
|
|
174
|
+
FROM pg_attribute a
|
|
175
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
176
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
177
|
+
WHERE c.relname = #{@connection.quote(@table)}
|
|
178
|
+
AND n.nspname = #{schema_sql}
|
|
179
|
+
AND a.attname = 'embedding' AND a.attnum > 0
|
|
180
|
+
SQL
|
|
181
|
+
row = rows.first
|
|
182
|
+
return nil unless row
|
|
183
|
+
|
|
184
|
+
dimension = row['dimension'].to_i
|
|
185
|
+
dimension.positive? ? dimension : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Iterate over every stored id without loading vectors.
|
|
189
|
+
#
|
|
190
|
+
# One `SELECT id` — the embed pipeline uses this to reconcile the
|
|
191
|
+
# durable store against the units extraction still holds, and paying
|
|
192
|
+
# for the vector columns to answer a question about ids would be
|
|
193
|
+
# pointless IO.
|
|
194
|
+
#
|
|
195
|
+
# @see Interface#each_id
|
|
196
|
+
def each_id(&block)
|
|
197
|
+
return enum_for(:each_id) unless block
|
|
198
|
+
|
|
199
|
+
rows = @connection.execute("SELECT id FROM #{qualified_table}")
|
|
200
|
+
rows.each { |row| yield(row['id']) }
|
|
201
|
+
end
|
|
202
|
+
|
|
124
203
|
# @see Interface#delete
|
|
125
204
|
def delete(id)
|
|
126
205
|
quoted_id = @connection.quote(id)
|
|
127
|
-
@connection.execute("DELETE FROM #{
|
|
206
|
+
@connection.execute("DELETE FROM #{qualified_table} WHERE id = #{quoted_id}")
|
|
128
207
|
end
|
|
129
208
|
|
|
130
209
|
# @see Interface#delete_by_filter
|
|
131
210
|
def delete_by_filter(filters)
|
|
132
211
|
where_clause = build_where(filters)
|
|
133
|
-
@connection.execute("DELETE FROM #{
|
|
212
|
+
@connection.execute("DELETE FROM #{qualified_table} #{where_clause}")
|
|
134
213
|
end
|
|
135
214
|
|
|
136
215
|
# @see Interface#count
|
|
137
216
|
def count
|
|
138
|
-
result = @connection.execute("SELECT COUNT(*) AS count FROM #{
|
|
217
|
+
result = @connection.execute("SELECT COUNT(*) AS count FROM #{qualified_table}")
|
|
139
218
|
result.first['count'].to_i
|
|
140
219
|
end
|
|
141
220
|
|
|
142
221
|
private
|
|
143
222
|
|
|
223
|
+
def normalize_dimensions(value)
|
|
224
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
225
|
+
|
|
226
|
+
raise ArgumentError, 'dimensions must be a positive Integer'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_identifier!(name, value, original)
|
|
230
|
+
return if TABLE_NAME_PATTERN.match?(value)
|
|
231
|
+
|
|
232
|
+
raise ArgumentError, "#{name} must be a PostgreSQL identifier, got #{original.inspect}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def quote_identifier(value)
|
|
236
|
+
@connection.respond_to?(:quote_table_name) ? @connection.quote_table_name(value) : value
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def qualified_table
|
|
240
|
+
[@schema, @table].compact.map { |part| quote_identifier(part) }.join('.')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def schema_sql
|
|
244
|
+
@schema ? @connection.quote(@schema) : 'current_schema()'
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Collapse duplicate ids within a batch, keeping the LAST occurrence
|
|
248
|
+
# of each id (upsert semantics — the final write wins). Duplicates
|
|
249
|
+
# signal an upstream problem (cross-type identifier twins, orphaned
|
|
250
|
+
# files re-embedding one unit twice), so dropped ids are logged
|
|
251
|
+
# rather than silently discarded.
|
|
252
|
+
#
|
|
253
|
+
# @param entries [Array<Hash>] Batch entries with :id keys
|
|
254
|
+
# @return [Array<Hash>] Entries with at most one row per id, original
|
|
255
|
+
# order preserved
|
|
256
|
+
def deduplicate_entries(entries)
|
|
257
|
+
deduped = entries.reverse.uniq { |entry| entry[:id] }.reverse
|
|
258
|
+
return entries if deduped.length == entries.length
|
|
259
|
+
|
|
260
|
+
duplicated = entries.group_by { |entry| entry[:id] }
|
|
261
|
+
.select { |_, occurrences| occurrences.length > 1 }
|
|
262
|
+
.keys
|
|
263
|
+
warn '[Woods::Storage::Pgvector] store_batch received duplicate ids ' \
|
|
264
|
+
"(keeping the last occurrence of each): #{duplicated.join(', ')}"
|
|
265
|
+
deduped
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Execute the multi-row upsert, converting a cardinality/uniqueness
|
|
269
|
+
# violation that still escapes the in-batch dedup into a
|
|
270
|
+
# {Woods::Error} naming every id in the batch — the raw PostgreSQL
|
|
271
|
+
# error names no id, which makes a partially-landed embed (earlier
|
|
272
|
+
# batches already committed) very hard to diagnose.
|
|
273
|
+
#
|
|
274
|
+
# @param sql [String] The INSERT ... ON CONFLICT statement
|
|
275
|
+
# @param entries [Array<Hash>] The batch, used for the error message
|
|
276
|
+
# @raise [Woods::Error] on a cardinality or uniqueness violation
|
|
277
|
+
def execute_upsert(sql, entries)
|
|
278
|
+
@connection.execute(sql)
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
raise unless conflict_violation?(e)
|
|
281
|
+
|
|
282
|
+
ids = entries.map { |entry| entry[:id] }
|
|
283
|
+
raise Woods::Error,
|
|
284
|
+
"pgvector batch upsert hit a duplicate-row conflict (#{e.class}: #{e.message.strip}). " \
|
|
285
|
+
"Batch ids: #{ids.join(', ')}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Duck-typed detection of PostgreSQL cardinality/uniqueness
|
|
289
|
+
# violations. The gem does not depend on the pg gem, and the error
|
|
290
|
+
# usually arrives wrapped in ActiveRecord::StatementInvalid, so match
|
|
291
|
+
# on class name and message across the error and its cause.
|
|
292
|
+
#
|
|
293
|
+
# @param error [StandardError]
|
|
294
|
+
# @return [Boolean]
|
|
295
|
+
def conflict_violation?(error)
|
|
296
|
+
[error, error.cause].compact.any? do |err|
|
|
297
|
+
err.class.name.to_s.match?(/CardinalityViolation|UniqueViolation/) ||
|
|
298
|
+
err.message.to_s.match?(/cannot affect row a second time|duplicate key value/)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
144
302
|
# Format a single entry as a SQL VALUES tuple.
|
|
145
303
|
#
|
|
146
304
|
# @param id [String] Unique identifier
|