phronomy 0.7.0 → 0.8.0
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/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module VectorStore
|
|
8
|
+
# Mixin that defines the async interface for VectorStore backends.
|
|
9
|
+
#
|
|
10
|
+
# Mixing this module into a VectorStore class provides three choices:
|
|
11
|
+
#
|
|
12
|
+
# 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
|
|
13
|
+
# that route through {BlockingAdapterPool} (the previous behaviour).
|
|
14
|
+
#
|
|
15
|
+
# 2. **Override selectively** — override only the async methods where the
|
|
16
|
+
# backend has a native async driver, while the remaining methods fall back
|
|
17
|
+
# to the pool.
|
|
18
|
+
#
|
|
19
|
+
# 3. **Implement all natively** — override all async methods to avoid pool
|
|
20
|
+
# allocation entirely.
|
|
21
|
+
#
|
|
22
|
+
# @example Native async search (no pool worker thread allocated)
|
|
23
|
+
# class MyFastStore < Phronomy::Agent::Context::Knowledge::VectorStore::Base
|
|
24
|
+
# include Phronomy::Agent::Context::Knowledge::VectorStore::AsyncBackend
|
|
25
|
+
#
|
|
26
|
+
# def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
|
|
27
|
+
# # Returns a PendingOperation backed by a native async driver.
|
|
28
|
+
# native_async_search(query_embedding, k)
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
module AsyncBackend
|
|
34
|
+
# Async variant of {VectorStore::Base#add}.
|
|
35
|
+
#
|
|
36
|
+
# Submits the add call to {BlockingAdapterPool} by default.
|
|
37
|
+
# Override to use a native async driver.
|
|
38
|
+
#
|
|
39
|
+
# @param id [String]
|
|
40
|
+
# @param embedding [Array<Float>]
|
|
41
|
+
# @param metadata [Hash]
|
|
42
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
43
|
+
# @param timeout [Numeric, nil]
|
|
44
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
45
|
+
# @api public
|
|
46
|
+
def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
|
|
47
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
48
|
+
timeout: timeout,
|
|
49
|
+
cancellation_token: cancellation_token
|
|
50
|
+
) do
|
|
51
|
+
add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Async variant of {VectorStore::Base#search}.
|
|
56
|
+
#
|
|
57
|
+
# Submits the search call to {BlockingAdapterPool} by default.
|
|
58
|
+
# Override to use a native async driver.
|
|
59
|
+
#
|
|
60
|
+
# @param query_embedding [Array<Float>]
|
|
61
|
+
# @param k [Integer]
|
|
62
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
63
|
+
# @param timeout [Numeric, nil]
|
|
64
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
65
|
+
# @api public
|
|
66
|
+
def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
|
|
67
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
68
|
+
timeout: timeout,
|
|
69
|
+
cancellation_token: cancellation_token
|
|
70
|
+
) do
|
|
71
|
+
search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Async variant of {VectorStore::Base#remove}.
|
|
76
|
+
#
|
|
77
|
+
# Submits the remove call to {BlockingAdapterPool} by default.
|
|
78
|
+
# Override to use a native async driver.
|
|
79
|
+
#
|
|
80
|
+
# @param id [String]
|
|
81
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
82
|
+
# @param timeout [Numeric, nil]
|
|
83
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
84
|
+
# @api public
|
|
85
|
+
def remove_async(id:, cancellation_token: nil, timeout: nil)
|
|
86
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
87
|
+
timeout: timeout,
|
|
88
|
+
cancellation_token: cancellation_token
|
|
89
|
+
) do
|
|
90
|
+
remove(id: id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Async variant of {VectorStore::Base#clear}.
|
|
95
|
+
#
|
|
96
|
+
# Submits the clear call to {BlockingAdapterPool} by default.
|
|
97
|
+
# Override to use a native async driver.
|
|
98
|
+
#
|
|
99
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
100
|
+
# @param timeout [Numeric, nil]
|
|
101
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
102
|
+
# @api public
|
|
103
|
+
def clear_async(cancellation_token: nil, timeout: nil)
|
|
104
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
105
|
+
timeout: timeout,
|
|
106
|
+
cancellation_token: cancellation_token
|
|
107
|
+
) do
|
|
108
|
+
clear
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module VectorStore
|
|
8
|
+
# Abstract interface for vector stores.
|
|
9
|
+
#
|
|
10
|
+
# Implementations manage a collection of (embedding, metadata) pairs and
|
|
11
|
+
# support similarity search.
|
|
12
|
+
#
|
|
13
|
+
# Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
|
|
14
|
+
# are provided by the {AsyncBackend} mixin which defaults to routing calls
|
|
15
|
+
# through {BlockingAdapterPool}. Backends with native async drivers may
|
|
16
|
+
# override individual async methods without touching the pool at all.
|
|
17
|
+
class Base
|
|
18
|
+
include AsyncBackend
|
|
19
|
+
|
|
20
|
+
# Add a document with its vector embedding.
|
|
21
|
+
#
|
|
22
|
+
# @param id [String] unique document identifier
|
|
23
|
+
# @param embedding [Array<Float>] vector embedding
|
|
24
|
+
# @param metadata [Hash] arbitrary metadata (e.g. the original message object)
|
|
25
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
26
|
+
# @api public
|
|
27
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
28
|
+
cancellation_token&.raise_if_cancelled!
|
|
29
|
+
raise NotImplementedError, "#{self.class}#add is not implemented"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Return the k most similar documents to the query embedding.
|
|
33
|
+
#
|
|
34
|
+
# @param query_embedding [Array<Float>]
|
|
35
|
+
# @param k [Integer] number of results
|
|
36
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
37
|
+
# @return [Array<Hash>] each element: { id:, score:, metadata: }
|
|
38
|
+
# @api public
|
|
39
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
40
|
+
cancellation_token&.raise_if_cancelled!
|
|
41
|
+
raise NotImplementedError, "#{self.class}#search is not implemented"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Remove a single document by id.
|
|
45
|
+
#
|
|
46
|
+
# @param id [String] document identifier
|
|
47
|
+
# @api public
|
|
48
|
+
def remove(id:)
|
|
49
|
+
raise NotImplementedError, "#{self.class}#remove is not implemented"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Remove all documents.
|
|
53
|
+
def clear
|
|
54
|
+
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Return the number of documents stored.
|
|
58
|
+
#
|
|
59
|
+
# @return [Integer]
|
|
60
|
+
# @api public
|
|
61
|
+
def size
|
|
62
|
+
raise NotImplementedError, "#{self.class}#size is not implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Validates that embedding has the expected dimension.
|
|
68
|
+
# Raises ArgumentError if sizes differ.
|
|
69
|
+
# A nil expected_dimension is a no-op (dimension not yet established).
|
|
70
|
+
def validate_embedding_dimension!(embedding, expected_dimension)
|
|
71
|
+
return unless expected_dimension
|
|
72
|
+
|
|
73
|
+
actual = embedding.size
|
|
74
|
+
return if actual == expected_dimension
|
|
75
|
+
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Validates that k is a positive integer.
|
|
81
|
+
# Accepts any value accepted by Integer() (e.g. "5"), but raises
|
|
82
|
+
# ArgumentError for non-integer strings, zero, and negative values.
|
|
83
|
+
def validate_k!(k)
|
|
84
|
+
int_k = Integer(k)
|
|
85
|
+
raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
|
|
86
|
+
int_k
|
|
87
|
+
rescue ArgumentError => e
|
|
88
|
+
raise ArgumentError, "k must be a positive integer: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module VectorStore
|
|
8
|
+
# Pure-Ruby in-memory vector store using cosine similarity.
|
|
9
|
+
#
|
|
10
|
+
# Intended for tests, short-lived agents, and Retrieval::Semantic scenarios where
|
|
11
|
+
# the message count is small enough that a linear scan is fast enough.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# store = Phronomy::Agent::Context::Knowledge::VectorStore::InMemory.new
|
|
15
|
+
# store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
|
|
16
|
+
# results = store.search(query_embedding: [0.1, 0.8], k: 3)
|
|
17
|
+
class InMemory < Base
|
|
18
|
+
# @param dimension [Integer, nil] expected embedding dimension.
|
|
19
|
+
# When nil, the dimension is inferred from the first call to #add.
|
|
20
|
+
# For multi-threaded use, pass dimension: explicitly; concurrent first
|
|
21
|
+
# adds are not guaranteed to be race-free.
|
|
22
|
+
# @api public
|
|
23
|
+
def initialize(dimension: nil)
|
|
24
|
+
@documents = {}
|
|
25
|
+
@expected_dimension = dimension
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param id [String]
|
|
29
|
+
# @param embedding [Array<Float>]
|
|
30
|
+
# @param metadata [Hash]
|
|
31
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
32
|
+
# @api public
|
|
33
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
34
|
+
cancellation_token&.raise_if_cancelled!
|
|
35
|
+
# Establish expected dimension on first add, then validate.
|
|
36
|
+
@expected_dimension ||= embedding.size
|
|
37
|
+
validate_embedding_dimension!(embedding, @expected_dimension)
|
|
38
|
+
@documents[id] = {embedding: embedding, metadata: metadata}
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param query_embedding [Array<Float>]
|
|
43
|
+
# @param k [Integer]
|
|
44
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
45
|
+
# @return [Array<Hash>] sorted by descending score
|
|
46
|
+
# @api public
|
|
47
|
+
# mutant:disable - genuine equivalent mutations: doc.fetch(:embedding) vs doc[:embedding] (key
|
|
48
|
+
# always present); {id:, score:, metadata: doc.fetch(:metadata)} shorthand+fetch vs []
|
|
49
|
+
# (key always present); -r.fetch(:score) vs -r[:score] (key always present); snapshot = @documents
|
|
50
|
+
# vs .dup is equivalent in single-threaded tests (GVL makes Hash#dup atomic, no behaviour
|
|
51
|
+
# difference under test isolation)
|
|
52
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
53
|
+
cancellation_token&.raise_if_cancelled!
|
|
54
|
+
k = validate_k!(k)
|
|
55
|
+
# search never establishes dimension; validate only when dimension is known.
|
|
56
|
+
validate_embedding_dimension!(query_embedding, @expected_dimension)
|
|
57
|
+
# Take an atomic snapshot before iterating. Hash#dup is a C-level
|
|
58
|
+
# call that completes without releasing the GVL, so it is atomic with
|
|
59
|
+
# respect to any other Ruby thread. Iterating the copy instead of
|
|
60
|
+
# @documents directly prevents "can't add a new key into hash during
|
|
61
|
+
# iteration" when a concurrent thread calls #add.
|
|
62
|
+
snapshot = @documents.dup
|
|
63
|
+
results = snapshot.map do |id, doc|
|
|
64
|
+
score = cosine_similarity(query_embedding, doc[:embedding])
|
|
65
|
+
{id: id, score: score, metadata: doc[:metadata]}
|
|
66
|
+
end
|
|
67
|
+
results.sort_by { |r| -r[:score] }.first(k)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def remove(id:)
|
|
71
|
+
@documents.delete(id)
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def clear
|
|
76
|
+
@documents.clear
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Integer] number of documents stored
|
|
81
|
+
# @api public
|
|
82
|
+
def size
|
|
83
|
+
@documents.size
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# mutant:disable - empty-vector early-return condition variants (if false, if nil, if a.empty?,
|
|
89
|
+
# if b.empty?, if a.empty? && b.empty?, if a.empty? || false, if false || b.empty?,
|
|
90
|
+
# if nil || b.empty?, if nil && b.empty?) are genuine equivalents: dimension validation in
|
|
91
|
+
# #add and #search enforces same-size embeddings, so a.empty? iff b.empty?; when both are
|
|
92
|
+
# empty norm_a = sqrt(0) = 0 so the norm_a.zero? guard returns 0.0 anyway
|
|
93
|
+
def cosine_similarity(a, b)
|
|
94
|
+
return 0.0 if a.empty? || b.empty?
|
|
95
|
+
|
|
96
|
+
dot = a.zip(b).sum { |x, y| x * y }
|
|
97
|
+
norm_a = Math.sqrt(a.sum { |x| x**2 })
|
|
98
|
+
norm_b = Math.sqrt(b.sum { |x| x**2 })
|
|
99
|
+
|
|
100
|
+
return 0.0 if norm_a.zero? || norm_b.zero?
|
|
101
|
+
|
|
102
|
+
dot / (norm_a * norm_b)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
module Context
|
|
8
|
+
module Knowledge
|
|
9
|
+
module VectorStore
|
|
10
|
+
# PostgreSQL-backed vector store using the pgvector extension.
|
|
11
|
+
#
|
|
12
|
+
# Requires:
|
|
13
|
+
# - The +pgvector+ gem (add to your Gemfile)
|
|
14
|
+
# - An ActiveRecord model class with the following columns:
|
|
15
|
+
# id (string / uuid)
|
|
16
|
+
# embedding (vector — from the pgvector column type)
|
|
17
|
+
# metadata (text or jsonb — stores arbitrary metadata as JSON)
|
|
18
|
+
#
|
|
19
|
+
# @example Usage
|
|
20
|
+
# store = Phronomy::Agent::Context::Knowledge::VectorStore::Pgvector.new(model_class: VectorDocument)
|
|
21
|
+
# store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
|
|
22
|
+
# results = store.search(query_embedding: [0.1, 0.8], k: 5)
|
|
23
|
+
class Pgvector < Base
|
|
24
|
+
# @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
|
|
25
|
+
# @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
|
|
26
|
+
# pre-validation. When nil, dimension enforcement is delegated to the
|
|
27
|
+
# database schema; no pre-validation is performed by Phronomy.
|
|
28
|
+
# @api public
|
|
29
|
+
def initialize(model_class:, dimension: nil)
|
|
30
|
+
begin
|
|
31
|
+
require "pgvector"
|
|
32
|
+
rescue LoadError
|
|
33
|
+
raise LoadError,
|
|
34
|
+
"pgvector gem is required for Phronomy::Agent::Context::Knowledge::VectorStore::Pgvector. " \
|
|
35
|
+
"Add `gem 'pgvector'` to your Gemfile."
|
|
36
|
+
end
|
|
37
|
+
@model_class = model_class
|
|
38
|
+
@dimension = dimension
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param id [String]
|
|
42
|
+
# @param embedding [Array<Float>]
|
|
43
|
+
# @param metadata [Hash]
|
|
44
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
45
|
+
# @api public
|
|
46
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
47
|
+
cancellation_token&.raise_if_cancelled!
|
|
48
|
+
validate_embedding_dimension!(embedding, @dimension)
|
|
49
|
+
@model_class.upsert(
|
|
50
|
+
{id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
|
|
51
|
+
unique_by: :id
|
|
52
|
+
)
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param query_embedding [Array<Float>]
|
|
57
|
+
# @param k [Integer]
|
|
58
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
59
|
+
# @return [Array<Hash>] sorted by descending similarity score
|
|
60
|
+
# @api public
|
|
61
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
62
|
+
cancellation_token&.raise_if_cancelled!
|
|
63
|
+
k_safe = validate_k!(k)
|
|
64
|
+
validate_embedding_dimension!(query_embedding, @dimension)
|
|
65
|
+
vec = safe_vector_literal(query_embedding)
|
|
66
|
+
conn = @model_class.connection
|
|
67
|
+
quoted_vec = "#{conn.quote(vec)}::vector"
|
|
68
|
+
|
|
69
|
+
@model_class
|
|
70
|
+
.select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
|
|
71
|
+
.order("embedding <=> #{quoted_vec}")
|
|
72
|
+
.limit(k_safe)
|
|
73
|
+
.map do |r|
|
|
74
|
+
{
|
|
75
|
+
id: r.id.to_s,
|
|
76
|
+
score: r.score.to_f,
|
|
77
|
+
metadata: parse_metadata(r.metadata)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def remove(id:)
|
|
83
|
+
@model_class.where(id: id).delete_all
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def clear
|
|
88
|
+
@model_class.delete_all
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the number of documents in the backing table.
|
|
93
|
+
def size
|
|
94
|
+
@model_class.count
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Parses a metadata value returned by the pg driver.
|
|
100
|
+
# Handles NULL (nil), already-parsed Hash, and JSON string forms.
|
|
101
|
+
def parse_metadata(raw)
|
|
102
|
+
return {} if raw.nil?
|
|
103
|
+
return symbolize_hash_keys(raw) if raw.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
parsed = JSON.parse(raw.to_s, symbolize_names: true)
|
|
106
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
107
|
+
rescue JSON::ParserError
|
|
108
|
+
{}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Recursively symbolizes keys for an already-parsed Hash.
|
|
112
|
+
def symbolize_hash_keys(hash)
|
|
113
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
114
|
+
h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validates that all elements are numeric and converts to a pgvector-
|
|
119
|
+
# compatible literal string (e.g. "[1.0,0.5,-0.3]").
|
|
120
|
+
def safe_vector_literal(embedding)
|
|
121
|
+
"[#{embedding.map { |v| Float(v) }.join(",")}]"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Returns a validated vector for the upsert call.
|
|
125
|
+
def safe_vector(embedding)
|
|
126
|
+
safe_vector_literal(embedding)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
module Context
|
|
8
|
+
module Knowledge
|
|
9
|
+
module VectorStore
|
|
10
|
+
# Redis-backed vector store using the RediSearch module (FT.* commands).
|
|
11
|
+
#
|
|
12
|
+
# Requires:
|
|
13
|
+
# - The +redis+ gem (add to your Gemfile)
|
|
14
|
+
# - A Redis server with the RediSearch (RedisSearch) module enabled
|
|
15
|
+
# (or Redis Stack which bundles RediSearch)
|
|
16
|
+
#
|
|
17
|
+
# Vectors are stored as FLOAT32 binary blobs in Redis Hash fields and
|
|
18
|
+
# searched using the KNN approximate-nearest-neighbour algorithm.
|
|
19
|
+
#
|
|
20
|
+
# @example Usage
|
|
21
|
+
# redis = Redis.new(url: "redis://localhost:6379")
|
|
22
|
+
# store = Phronomy::Agent::Context::Knowledge::VectorStore::RedisSearch.new(redis: redis, dimension: 1536)
|
|
23
|
+
# store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
|
|
24
|
+
# results = store.search(query_embedding: [0.1, 0.8], k: 5)
|
|
25
|
+
class RedisSearch < Base
|
|
26
|
+
DOC_PREFIX = "phronomy_doc:"
|
|
27
|
+
private_constant :DOC_PREFIX
|
|
28
|
+
|
|
29
|
+
# @param redis [Redis] configured Redis client
|
|
30
|
+
# @param index_name [String] RediSearch index name
|
|
31
|
+
# @param dimension [Integer, nil] vector dimension; auto-detected on first add.
|
|
32
|
+
# When connecting to an **existing** RediSearch index, you MUST pass
|
|
33
|
+
# dimension: explicitly. Without it, a freshly constructed instance
|
|
34
|
+
# treats the index as uninitialized until #add is called, and #search
|
|
35
|
+
# silently returns [] in the meantime.
|
|
36
|
+
# @api public
|
|
37
|
+
def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
|
|
38
|
+
begin
|
|
39
|
+
require "redis"
|
|
40
|
+
rescue LoadError
|
|
41
|
+
raise LoadError,
|
|
42
|
+
"redis gem is required for Phronomy::Agent::Context::Knowledge::VectorStore::RedisSearch. " \
|
|
43
|
+
"Add `gem 'redis'` to your Gemfile."
|
|
44
|
+
end
|
|
45
|
+
@redis = redis
|
|
46
|
+
@index_name = index_name
|
|
47
|
+
@dimension = dimension
|
|
48
|
+
@index_created = false
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param id [String]
|
|
53
|
+
# @param embedding [Array<Float>]
|
|
54
|
+
# @param metadata [Hash]
|
|
55
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
56
|
+
# @api public
|
|
57
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
58
|
+
cancellation_token&.raise_if_cancelled!
|
|
59
|
+
# Establish expected dimension on first add (not race-free for concurrent
|
|
60
|
+
# first adds), then validate, then create/reuse the index.
|
|
61
|
+
@dimension ||= embedding.size
|
|
62
|
+
validate_embedding_dimension!(embedding, @dimension)
|
|
63
|
+
ensure_index!(@dimension)
|
|
64
|
+
@redis.call(
|
|
65
|
+
"HSET", "#{DOC_PREFIX}#{id}",
|
|
66
|
+
"embedding", pack_vector(embedding),
|
|
67
|
+
"metadata", metadata.to_json
|
|
68
|
+
)
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param query_embedding [Array<Float>]
|
|
73
|
+
# @param k [Integer]
|
|
74
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
75
|
+
# @return [Array<Hash>] sorted by descending similarity score
|
|
76
|
+
# @api public
|
|
77
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
78
|
+
cancellation_token&.raise_if_cancelled!
|
|
79
|
+
# search never establishes dimension. If dimension is unknown and the
|
|
80
|
+
# index has not been created yet, there are no documents to return.
|
|
81
|
+
return [] if @dimension.nil? && !@index_created
|
|
82
|
+
|
|
83
|
+
validate_embedding_dimension!(query_embedding, @dimension)
|
|
84
|
+
ensure_index!(@dimension)
|
|
85
|
+
k_safe = validate_k!(k)
|
|
86
|
+
blob = pack_vector(query_embedding)
|
|
87
|
+
|
|
88
|
+
raw = @redis.call(
|
|
89
|
+
"FT.SEARCH", @index_name,
|
|
90
|
+
"*=>[KNN #{k_safe} @embedding $BLOB AS score]",
|
|
91
|
+
"PARAMS", 2, "BLOB", blob,
|
|
92
|
+
"SORTBY", "score",
|
|
93
|
+
"RETURN", 2, "score", "metadata",
|
|
94
|
+
"DIALECT", 2
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
parse_results(raw)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def remove(id:)
|
|
101
|
+
@redis.call("DEL", "#{DOC_PREFIX}#{id}")
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the number of documents indexed.
|
|
106
|
+
# Queries FT.INFO when the index has been created; returns 0 otherwise.
|
|
107
|
+
def size
|
|
108
|
+
return 0 unless @index_created
|
|
109
|
+
|
|
110
|
+
raw = @redis.call("FT.INFO", @index_name)
|
|
111
|
+
return 0 unless raw.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
idx = raw.index("num_docs")
|
|
114
|
+
idx ? raw[idx + 1].to_i : 0
|
|
115
|
+
rescue
|
|
116
|
+
0
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def clear
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
begin
|
|
122
|
+
@redis.call("FT.DROPINDEX", @index_name, "DD")
|
|
123
|
+
rescue => e
|
|
124
|
+
raise unless e.message.to_s.include?("Unknown Index name")
|
|
125
|
+
end
|
|
126
|
+
@index_created = false
|
|
127
|
+
end
|
|
128
|
+
self
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def ensure_index!(dim)
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
return if @index_created
|
|
136
|
+
|
|
137
|
+
@dimension ||= dim
|
|
138
|
+
begin
|
|
139
|
+
@redis.call(
|
|
140
|
+
"FT.CREATE", @index_name,
|
|
141
|
+
"ON", "HASH",
|
|
142
|
+
"PREFIX", 1, DOC_PREFIX,
|
|
143
|
+
"SCHEMA",
|
|
144
|
+
"embedding", "VECTOR", "FLAT", 6,
|
|
145
|
+
"TYPE", "FLOAT32",
|
|
146
|
+
"DIM", @dimension,
|
|
147
|
+
"DISTANCE_METRIC", "COSINE",
|
|
148
|
+
"metadata", "TEXT"
|
|
149
|
+
)
|
|
150
|
+
rescue => e
|
|
151
|
+
raise unless e.message.to_s.include?("Index already exists")
|
|
152
|
+
end
|
|
153
|
+
@index_created = true
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Pack a Float array as a FLOAT32 binary string for RediSearch.
|
|
158
|
+
def pack_vector(embedding)
|
|
159
|
+
embedding.map { |v| Float(v) }.pack("f*")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Parse the raw FT.SEARCH response into the standard Hash format.
|
|
163
|
+
#
|
|
164
|
+
# Redis FT.SEARCH returns: [count, key1, [field, value, ...], key2, ...]
|
|
165
|
+
def parse_results(raw)
|
|
166
|
+
return [] if raw.nil? || !raw.is_a?(Array) || raw.size < 2
|
|
167
|
+
|
|
168
|
+
results = []
|
|
169
|
+
i = 1
|
|
170
|
+
while i < raw.size
|
|
171
|
+
key = raw[i]
|
|
172
|
+
fields = raw[i + 1]
|
|
173
|
+
i += 2
|
|
174
|
+
|
|
175
|
+
next unless fields.is_a?(Array)
|
|
176
|
+
|
|
177
|
+
field_hash = fields.each_slice(2).to_h
|
|
178
|
+
score_str = field_hash["score"]
|
|
179
|
+
metadata_str = field_hash["metadata"]
|
|
180
|
+
|
|
181
|
+
next if score_str.nil?
|
|
182
|
+
|
|
183
|
+
id = key.to_s.delete_prefix(DOC_PREFIX)
|
|
184
|
+
# RediSearch returns cosine distance (0=identical, 2=opposite);
|
|
185
|
+
# convert to cosine similarity for consistency with other backends.
|
|
186
|
+
score = 1.0 - score_str.to_f
|
|
187
|
+
metadata = metadata_str ? JSON.parse(metadata_str, symbolize_names: true) : {}
|
|
188
|
+
|
|
189
|
+
results << {id: id, score: score, metadata: metadata}
|
|
190
|
+
end
|
|
191
|
+
results
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|