htm 0.0.30 → 0.0.32
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/.irbrc +2 -3
- data/.rubocop.yml +184 -0
- data/CHANGELOG.md +46 -0
- data/README.md +2 -0
- data/Rakefile +93 -12
- data/db/migrate/00008_create_node_relationships.rb +54 -0
- data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
- data/db/schema.sql +124 -1
- data/docs/api/database.md +35 -57
- data/docs/api/embedding-service.md +1 -1
- data/docs/api/index.md +26 -15
- data/docs/api/working-memory.md +8 -8
- data/docs/architecture/index.md +5 -7
- data/docs/architecture/overview.md +5 -8
- data/docs/assets/images/htm-architecture-overview.svg +1 -1
- data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
- data/docs/assets/images/htm-layered-architecture.svg +3 -3
- data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
- data/docs/database/README.md +1 -0
- data/docs/database_rake_tasks.md +20 -28
- data/docs/development/contributing.md +5 -5
- data/docs/development/index.md +4 -7
- data/docs/development/schema.md +71 -1
- data/docs/development/setup.md +40 -82
- data/docs/development/testing.md +1 -1
- data/docs/examples/file-loading.md +4 -4
- data/docs/examples/mcp-client.md +1 -1
- data/docs/getting-started/quick-start.md +4 -4
- data/docs/guides/adding-memories.md +14 -1
- data/docs/guides/configuration.md +5 -5
- data/docs/guides/context-assembly.md +4 -4
- data/docs/guides/file-loading.md +12 -12
- data/docs/guides/getting-started.md +2 -2
- data/docs/guides/long-term-memory.md +7 -27
- data/docs/guides/propositions.md +20 -19
- data/docs/guides/recalling-memories.md +5 -5
- data/docs/guides/tags.md +18 -13
- data/docs/multi_framework_support.md +1 -1
- data/docs/robots/hive-mind.md +1 -1
- data/docs/robots/multi-robot.md +2 -2
- data/docs/robots/robot-groups.md +1 -1
- data/docs/robots/two-tier-memory.md +72 -94
- data/docs/setup_local_database.md +8 -54
- data/docs/using_rake_tasks_in_your_app.md +6 -6
- data/examples/01_basic_usage.rb +1 -0
- data/examples/03_custom_llm_configuration.rb +1 -0
- data/examples/04_file_loader_usage.rb +1 -0
- data/examples/05_timeframe_demo.rb +1 -0
- data/examples/06_example_app/app.rb +1 -0
- data/examples/07_cli_app/htm_cli.rb +1 -0
- data/examples/09_mcp_client.rb +1 -0
- data/examples/10_telemetry/demo.rb +1 -0
- data/examples/11_robot_groups/multi_process.rb +1 -0
- data/examples/11_robot_groups/same_process.rb +1 -0
- data/examples/12_rails_app/.envrc +12 -0
- data/examples/12_rails_app/Gemfile +8 -3
- data/examples/12_rails_app/Gemfile.lock +94 -89
- data/examples/12_rails_app/README.md +70 -19
- data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
- data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
- data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
- data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
- data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
- data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
- data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
- data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
- data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
- data/examples/12_rails_app/app/javascript/application.js +1 -1
- data/examples/12_rails_app/app/models/application_record.rb +5 -0
- data/examples/12_rails_app/app/models/chat.rb +36 -0
- data/examples/12_rails_app/app/models/message.rb +5 -0
- data/examples/12_rails_app/app/models/model.rb +5 -0
- data/examples/12_rails_app/app/models/tool_call.rb +5 -0
- data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
- data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
- data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
- data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
- data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
- data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
- data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
- data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
- data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
- data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
- data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
- data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
- data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
- data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
- data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
- data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
- data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
- data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
- data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
- data/examples/12_rails_app/config/application.rb +1 -1
- data/examples/12_rails_app/config/database.yml +9 -5
- data/examples/12_rails_app/config/importmap.rb +1 -1
- data/examples/12_rails_app/config/initializers/htm.rb +9 -2
- data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
- data/examples/12_rails_app/config/routes.rb +39 -23
- data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
- data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
- data/examples/12_rails_app/db/schema.rb +67 -0
- data/examples/examples_helper.rb +25 -0
- data/lib/htm/circuit_breaker.rb +5 -6
- data/lib/htm/config/builder.rb +12 -12
- data/lib/htm/config/database.rb +21 -27
- data/lib/htm/config/defaults.yml +25 -13
- data/lib/htm/config/validator.rb +12 -18
- data/lib/htm/config.rb +93 -173
- data/lib/htm/database.rb +193 -199
- data/lib/htm/embedding_service.rb +4 -9
- data/lib/htm/integrations/sinatra.rb +7 -7
- data/lib/htm/job_adapter.rb +14 -21
- data/lib/htm/jobs/generate_embedding_job.rb +28 -44
- data/lib/htm/jobs/generate_propositions_job.rb +29 -55
- data/lib/htm/jobs/generate_relationships_job.rb +137 -0
- data/lib/htm/jobs/generate_tags_job.rb +45 -67
- data/lib/htm/loaders/markdown_loader.rb +65 -112
- data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
- data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
- data/lib/htm/long_term_memory/node_operations.rb +2 -2
- data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
- data/lib/htm/long_term_memory/tag_operations.rb +87 -120
- data/lib/htm/long_term_memory/vector_search.rb +1 -1
- data/lib/htm/long_term_memory.rb +2 -1
- data/lib/htm/mcp/cli.rb +59 -58
- data/lib/htm/mcp/server.rb +5 -6
- data/lib/htm/mcp/tools.rb +30 -36
- data/lib/htm/migration.rb +10 -10
- data/lib/htm/models/node.rb +2 -3
- data/lib/htm/models/node_relationship.rb +72 -0
- data/lib/htm/models/node_tag.rb +2 -2
- data/lib/htm/models/robot_node.rb +2 -2
- data/lib/htm/models/tag.rb +41 -28
- data/lib/htm/observability.rb +45 -51
- data/lib/htm/proposition_service.rb +3 -7
- data/lib/htm/query_cache.rb +13 -15
- data/lib/htm/railtie.rb +1 -2
- data/lib/htm/robot_group.rb +9 -9
- data/lib/htm/sequel_config.rb +1 -0
- data/lib/htm/sql_builder.rb +1 -1
- data/lib/htm/tag_service.rb +2 -6
- data/lib/htm/timeframe.rb +4 -5
- data/lib/htm/timeframe_extractor.rb +42 -83
- data/lib/htm/version.rb +1 -1
- data/lib/htm/workflows/remember_workflow.rb +112 -115
- data/lib/htm/working_memory.rb +21 -26
- data/lib/htm.rb +103 -116
- data/lib/tasks/db.rake +0 -2
- data/lib/tasks/doc.rake +14 -13
- data/lib/tasks/files.rake +5 -12
- data/lib/tasks/htm.rake +70 -71
- data/lib/tasks/jobs.rake +41 -47
- data/lib/tasks/tags.rake +3 -8
- metadata +28 -106
- data/lib/htm/config/section.rb +0 -74
- data/lib/htm/loaders/defaults_loader.rb +0 -166
- data/lib/htm/loaders/xdg_config_loader.rb +0 -116
|
@@ -45,7 +45,7 @@ class HTM
|
|
|
45
45
|
#
|
|
46
46
|
def search_hybrid(timeframe:, query:, limit:, embedding_service:, prefilter_limit: 100, metadata: {})
|
|
47
47
|
# Enforce limits to prevent DoS
|
|
48
|
-
safe_limit =
|
|
48
|
+
safe_limit = limit.to_i.clamp(1, MAX_HYBRID_LIMIT)
|
|
49
49
|
safe_prefilter = [prefilter_limit.to_i, 1].max
|
|
50
50
|
|
|
51
51
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -66,11 +66,34 @@ class HTM
|
|
|
66
66
|
|
|
67
67
|
private
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
def init_rrf_entry(result, rank)
|
|
70
|
+
{
|
|
71
|
+
'id' => result['id'],
|
|
72
|
+
'content' => result['content'],
|
|
73
|
+
'access_count' => result['access_count'],
|
|
74
|
+
'created_at' => result['created_at'],
|
|
75
|
+
'token_count' => result['token_count'],
|
|
76
|
+
'similarity' => 0.0,
|
|
77
|
+
'text_rank' => 0.0,
|
|
78
|
+
'tag_depth_score' => 0.0,
|
|
79
|
+
'matched_tags' => [],
|
|
80
|
+
'rrf_score' => 1.0 / (RRF_K + rank),
|
|
81
|
+
'vector_rank' => nil,
|
|
82
|
+
'fulltext_rank' => nil,
|
|
83
|
+
'tag_rank' => nil,
|
|
84
|
+
'sources' => []
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Hybrid search using Reciprocal Rank Fusion with retrieve-then-rerank
|
|
70
89
|
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
90
|
+
# Uses a single SQL CTE query instead of three round-trips:
|
|
91
|
+
# 1. Fulltext + tag candidates are retrieved first (cheap)
|
|
92
|
+
# 2. Vector similarity is computed only for those candidates (expensive but scoped)
|
|
93
|
+
# 3. RRF scoring merges all three rankings in SQL
|
|
94
|
+
#
|
|
95
|
+
# Trade-off: Queries with high semantic relevance but zero keyword/tag
|
|
96
|
+
# overlap will be missed. Use :vector strategy for pure semantic search.
|
|
74
97
|
#
|
|
75
98
|
# @param timeframe [nil, Range, Array<Range>] Time range(s) to search
|
|
76
99
|
# @param query [String] Search query
|
|
@@ -81,41 +104,241 @@ class HTM
|
|
|
81
104
|
# @return [Array<Hash>] Merged results with RRF scores
|
|
82
105
|
#
|
|
83
106
|
def search_hybrid_rrf(timeframe:, query:, limit:, embedding_service:, candidate_limit:, metadata: {})
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
tag_info = extract_rrf_tag_info(query)
|
|
108
|
+
literals = build_rrf_literals(query, tag_info[:search_tags], embedding_service)
|
|
109
|
+
filter_sql = build_rrf_filter_sql(timeframe, metadata)
|
|
110
|
+
|
|
111
|
+
sql = build_hybrid_cte_sql(
|
|
112
|
+
query_literal: literals[:query_literal],
|
|
113
|
+
embedding_literal: literals[:embedding_literal],
|
|
114
|
+
tag_literals: literals[:tag_literals],
|
|
115
|
+
additional_sql: filter_sql[:additional_sql],
|
|
116
|
+
additional_sql_n: filter_sql[:additional_sql_n],
|
|
117
|
+
candidate_limit: candidate_limit.to_i,
|
|
118
|
+
limit: limit.to_i
|
|
91
119
|
)
|
|
92
120
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
121
|
+
results = HTM.db.fetch(sql).all
|
|
122
|
+
top_results = post_process_rrf_results(results, tag_info[:tag_depth_map])
|
|
123
|
+
track_access(top_results.map { |r| r['id'] })
|
|
124
|
+
top_results
|
|
125
|
+
end
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
127
|
+
def extract_rrf_tag_info(query)
|
|
128
|
+
extraction = find_query_matching_tags(query, include_extracted: true)
|
|
129
|
+
extracted_tags = extraction[:extracted] || []
|
|
130
|
+
matched_db_tags = extraction[:matched] || []
|
|
131
|
+
{
|
|
132
|
+
search_tags: matched_db_tags.any? ? matched_db_tags : extracted_tags,
|
|
133
|
+
tag_depth_map: build_tag_depth_map(extracted_tags)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_rrf_literals(query, search_tags, embedding_service)
|
|
138
|
+
query_literal = HTM.db.literal(query)
|
|
139
|
+
query_embedding = embedding_service.embed(query)
|
|
140
|
+
has_embedding = query_embedding.is_a?(Array) && query_embedding.any?
|
|
141
|
+
embedding_literal = if has_embedding
|
|
142
|
+
padded = HTM::SqlBuilder.pad_embedding(query_embedding)
|
|
143
|
+
HTM.db.literal(HTM::SqlBuilder.sanitize_embedding(padded))
|
|
144
|
+
end
|
|
145
|
+
tag_literals = search_tags.any? ? search_tags.map { |t| HTM.db.literal(t) }.join(', ') : nil
|
|
146
|
+
{ query_literal: query_literal, embedding_literal: embedding_literal, tag_literals: tag_literals }
|
|
147
|
+
end
|
|
107
148
|
|
|
108
|
-
|
|
109
|
-
|
|
149
|
+
def build_rrf_filter_sql(timeframe, metadata)
|
|
150
|
+
tc = HTM::SqlBuilder.timeframe_condition(timeframe)
|
|
151
|
+
mc = HTM::SqlBuilder.metadata_condition(metadata)
|
|
152
|
+
tn = HTM::SqlBuilder.timeframe_condition(timeframe, table_alias: 'n')
|
|
153
|
+
mn = HTM::SqlBuilder.metadata_condition(metadata, table_alias: 'n')
|
|
154
|
+
{
|
|
155
|
+
additional_sql: [tc, mc].compact.then { |p| p.any? ? "AND #{p.join(' AND ')}" : "" },
|
|
156
|
+
additional_sql_n: [tn, mn].compact.then { |p| p.any? ? "AND #{p.join(' AND ')}" : "" }
|
|
157
|
+
}
|
|
158
|
+
end
|
|
110
159
|
|
|
111
|
-
|
|
112
|
-
|
|
160
|
+
def post_process_rrf_results(results, tag_depth_map)
|
|
161
|
+
results.map do |row|
|
|
162
|
+
r = row.transform_keys(&:to_s)
|
|
163
|
+
r['matched_tags'] = r.key?('matched_tags') ? parse_pg_array(r['matched_tags']) : []
|
|
164
|
+
r['tag_depth_score'] = r['matched_tags'].any? && tag_depth_map.any? ?
|
|
165
|
+
calculate_tag_depth_score(r['matched_tags'], tag_depth_map) : 0.0
|
|
166
|
+
sources = []
|
|
167
|
+
sources << 'vector' if r['vector_rank']
|
|
168
|
+
sources << 'fulltext' if r['fulltext_rank']
|
|
169
|
+
sources << 'tags' if r['tag_rank']
|
|
170
|
+
r['sources'] = sources
|
|
171
|
+
r['similarity'] = (r['similarity'] || 0.0).to_f
|
|
172
|
+
r['text_rank'] = (r['text_rank'] || 0.0).to_f
|
|
173
|
+
r['rrf_score'] = r['rrf_score'].to_f
|
|
174
|
+
r['vector_rank'] ||= nil
|
|
175
|
+
r['fulltext_rank'] ||= nil
|
|
176
|
+
r['tag_rank'] ||= nil
|
|
177
|
+
|
|
178
|
+
r
|
|
179
|
+
end
|
|
180
|
+
end
|
|
113
181
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
182
|
+
# Build the single-CTE SQL for hybrid search
|
|
183
|
+
#
|
|
184
|
+
# Conditionally includes/excludes CTEs based on available components:
|
|
185
|
+
# - Always: fulltext (tsvector + trigram) candidates
|
|
186
|
+
# - If tag_literals: tag candidates CTE
|
|
187
|
+
# - If embedding_literal: vector rerank CTE (only on candidate IDs)
|
|
188
|
+
#
|
|
189
|
+
# @param query_literal [String] SQL-escaped query string
|
|
190
|
+
# @param embedding_literal [String, nil] SQL-escaped embedding vector (nil to skip vector)
|
|
191
|
+
# @param tag_literals [String, nil] Comma-separated SQL-escaped tag names (nil to skip tags)
|
|
192
|
+
# @param additional_sql [String] Extra WHERE conditions (no table alias)
|
|
193
|
+
# @param additional_sql_n [String] Extra WHERE conditions (with 'n.' alias)
|
|
194
|
+
# @param candidate_limit [Integer] Max candidates per source
|
|
195
|
+
# @param limit [Integer] Final result limit
|
|
196
|
+
# @return [String] Complete SQL query
|
|
197
|
+
#
|
|
198
|
+
def build_hybrid_cte_sql(query_literal:, embedding_literal:, tag_literals:, additional_sql:, additional_sql_n:, candidate_limit:,
|
|
199
|
+
limit:)
|
|
200
|
+
has_embedding = !embedding_literal.nil?
|
|
201
|
+
has_tags = !tag_literals.nil?
|
|
202
|
+
|
|
203
|
+
ctes = fulltext_ctes_sql(query_literal, additional_sql, candidate_limit)
|
|
204
|
+
ctes << tag_candidates_cte_sql(tag_literals, additional_sql_n, candidate_limit) if has_tags
|
|
205
|
+
ctes << candidate_ids_cte_sql(has_tags)
|
|
206
|
+
ctes << vector_rerank_cte_sql(embedding_literal) if has_embedding
|
|
207
|
+
ctes.concat ranked_ctes_sql(has_tags, has_embedding)
|
|
208
|
+
ctes << rrf_scores_cte_sql(has_tags, has_embedding)
|
|
209
|
+
|
|
210
|
+
final_fields = build_final_select_fields(has_tags, has_embedding)
|
|
211
|
+
|
|
212
|
+
<<~SQL
|
|
213
|
+
WITH #{ctes.join(",\n")}
|
|
214
|
+
SELECT #{final_fields.join(",\n ")}
|
|
215
|
+
FROM rrf_scores rrf
|
|
216
|
+
JOIN nodes n ON n.id = rrf.id
|
|
217
|
+
ORDER BY rrf.rrf_score DESC
|
|
218
|
+
LIMIT #{limit}
|
|
219
|
+
SQL
|
|
220
|
+
end
|
|
117
221
|
|
|
118
|
-
|
|
222
|
+
def fulltext_ctes_sql(query_literal, additional_sql, candidate_limit)
|
|
223
|
+
[<<~SQL.chomp, <<~SQL.chomp, <<~SQL.chomp]
|
|
224
|
+
tsvector_matches AS (
|
|
225
|
+
SELECT id,
|
|
226
|
+
(1.0 + ts_rank(to_tsvector('english', content), plainto_tsquery('english', #{query_literal}))) as text_rank
|
|
227
|
+
FROM nodes
|
|
228
|
+
WHERE deleted_at IS NULL
|
|
229
|
+
AND to_tsvector('english', content) @@ plainto_tsquery('english', #{query_literal})
|
|
230
|
+
#{additional_sql}
|
|
231
|
+
)
|
|
232
|
+
SQL
|
|
233
|
+
trigram_matches AS (
|
|
234
|
+
SELECT id,
|
|
235
|
+
similarity(content, #{query_literal}) as text_rank
|
|
236
|
+
FROM nodes
|
|
237
|
+
WHERE deleted_at IS NULL
|
|
238
|
+
AND similarity(content, #{query_literal}) >= 0.1
|
|
239
|
+
AND id NOT IN (SELECT id FROM tsvector_matches)
|
|
240
|
+
#{additional_sql}
|
|
241
|
+
)
|
|
242
|
+
SQL
|
|
243
|
+
fulltext_candidates AS (
|
|
244
|
+
SELECT * FROM tsvector_matches
|
|
245
|
+
UNION ALL
|
|
246
|
+
SELECT * FROM trigram_matches
|
|
247
|
+
ORDER BY text_rank DESC
|
|
248
|
+
LIMIT #{candidate_limit}
|
|
249
|
+
)
|
|
250
|
+
SQL
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def tag_candidates_cte_sql(tag_literals, additional_sql_n, candidate_limit)
|
|
254
|
+
<<~SQL.chomp
|
|
255
|
+
tag_candidates AS (
|
|
256
|
+
SELECT n.id, array_agg(t.name) as matched_tags, count(t.name) as tag_match_count
|
|
257
|
+
FROM nodes n
|
|
258
|
+
JOIN node_tags nt ON nt.node_id = n.id
|
|
259
|
+
JOIN tags t ON t.id = nt.tag_id
|
|
260
|
+
WHERE n.deleted_at IS NULL
|
|
261
|
+
AND t.name IN (#{tag_literals})
|
|
262
|
+
#{additional_sql_n}
|
|
263
|
+
GROUP BY n.id
|
|
264
|
+
LIMIT #{candidate_limit}
|
|
265
|
+
)
|
|
266
|
+
SQL
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def candidate_ids_cte_sql(has_tags)
|
|
270
|
+
id_sources = ["SELECT id FROM fulltext_candidates"]
|
|
271
|
+
id_sources << "SELECT id FROM tag_candidates" if has_tags
|
|
272
|
+
<<~SQL.chomp
|
|
273
|
+
all_candidate_ids AS (
|
|
274
|
+
#{id_sources.join("\n UNION\n ")}
|
|
275
|
+
)
|
|
276
|
+
SQL
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def vector_rerank_cte_sql(embedding_literal)
|
|
280
|
+
<<~SQL.chomp
|
|
281
|
+
vector_rerank AS (
|
|
282
|
+
SELECT id,
|
|
283
|
+
1 - (embedding <=> #{embedding_literal}::vector) as similarity
|
|
284
|
+
FROM nodes
|
|
285
|
+
WHERE id IN (SELECT id FROM all_candidate_ids)
|
|
286
|
+
AND embedding IS NOT NULL
|
|
287
|
+
)
|
|
288
|
+
SQL
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def ranked_ctes_sql(has_tags, has_embedding)
|
|
292
|
+
ctes = [<<~SQL.chomp]
|
|
293
|
+
fulltext_ranked AS (
|
|
294
|
+
SELECT id, text_rank, ROW_NUMBER() OVER (ORDER BY text_rank DESC) as rank
|
|
295
|
+
FROM fulltext_candidates
|
|
296
|
+
)
|
|
297
|
+
SQL
|
|
298
|
+
ctes << <<~SQL.chomp if has_tags
|
|
299
|
+
tag_ranked AS (
|
|
300
|
+
SELECT id, matched_tags, ROW_NUMBER() OVER (ORDER BY tag_match_count DESC) as rank
|
|
301
|
+
FROM tag_candidates
|
|
302
|
+
)
|
|
303
|
+
SQL
|
|
304
|
+
ctes << <<~SQL.chomp if has_embedding
|
|
305
|
+
vector_ranked AS (
|
|
306
|
+
SELECT id, similarity, ROW_NUMBER() OVER (ORDER BY similarity DESC) as rank
|
|
307
|
+
FROM vector_rerank
|
|
308
|
+
)
|
|
309
|
+
SQL
|
|
310
|
+
ctes
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def rrf_scores_cte_sql(has_tags, has_embedding)
|
|
314
|
+
rrf_id = ["fr.id"]
|
|
315
|
+
rrf_id << "tr.id" if has_tags
|
|
316
|
+
rrf_id << "vr.id" if has_embedding
|
|
317
|
+
score_terms = ["COALESCE(1.0/(#{RRF_K} + fr.rank), 0)"]
|
|
318
|
+
score_terms << "COALESCE(1.0/(#{RRF_K} + tr.rank), 0)" if has_tags
|
|
319
|
+
score_terms << "COALESCE(1.0/(#{RRF_K} + vr.rank), 0)" if has_embedding
|
|
320
|
+
fields = ["COALESCE(#{rrf_id.join(', ')}) as id", "#{score_terms.join(' + ')} as rrf_score",
|
|
321
|
+
"fr.rank as fulltext_rank", "fr.text_rank"]
|
|
322
|
+
fields << "tr.rank as tag_rank" << "tr.matched_tags" if has_tags
|
|
323
|
+
fields << "vr.rank as vector_rank" << "vr.similarity" if has_embedding
|
|
324
|
+
from = "fulltext_ranked fr"
|
|
325
|
+
from += "\n FULL OUTER JOIN tag_ranked tr ON fr.id = tr.id" if has_tags
|
|
326
|
+
coalesce_id = has_tags ? "COALESCE(fr.id, tr.id)" : "fr.id"
|
|
327
|
+
from += "\n FULL OUTER JOIN vector_ranked vr ON #{coalesce_id} = vr.id" if has_embedding
|
|
328
|
+
<<~SQL.chomp
|
|
329
|
+
rrf_scores AS (
|
|
330
|
+
SELECT #{fields.join(",\n ")}
|
|
331
|
+
FROM #{from}
|
|
332
|
+
)
|
|
333
|
+
SQL
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def build_final_select_fields(has_tags, has_embedding)
|
|
337
|
+
fields = ["rrf.id", "n.content", "n.access_count", "n.created_at", "n.token_count",
|
|
338
|
+
"rrf.rrf_score", "rrf.fulltext_rank", "COALESCE(rrf.text_rank, 0.0) as text_rank"]
|
|
339
|
+
fields << "rrf.tag_rank" << "rrf.matched_tags" if has_tags
|
|
340
|
+
fields << "rrf.vector_rank" << "COALESCE(rrf.similarity, 0.0) as similarity" if has_embedding
|
|
341
|
+
fields
|
|
119
342
|
end
|
|
120
343
|
|
|
121
344
|
# Fetch candidates using vector similarity search
|
|
@@ -149,7 +372,7 @@ class HTM
|
|
|
149
372
|
|
|
150
373
|
where_clause = "WHERE #{conditions.join(' AND ')}"
|
|
151
374
|
|
|
152
|
-
#
|
|
375
|
+
# NOTE: Using Sequel.lit for the vector comparison since it needs special handling
|
|
153
376
|
embedding_literal = HTM.db.literal(embedding_str)
|
|
154
377
|
sql = <<~SQL
|
|
155
378
|
SELECT id, content, access_count, created_at, token_count,
|
|
@@ -282,7 +505,8 @@ class HTM
|
|
|
282
505
|
depth_score = calculate_tag_depth_score(matched_tags, tag_depth_map)
|
|
283
506
|
|
|
284
507
|
result.transform_keys(&:to_s).merge('tag_depth_score' => depth_score, 'matched_tags' => matched_tags)
|
|
285
|
-
end
|
|
508
|
+
end
|
|
509
|
+
results.sort_by { |r| -r['tag_depth_score'] }
|
|
286
510
|
end
|
|
287
511
|
|
|
288
512
|
# Build a map of tag prefixes to their depth information
|
|
@@ -336,13 +560,13 @@ class HTM
|
|
|
336
560
|
# Score is depth / max_depth
|
|
337
561
|
# e.g., "database:postgresql" matching query "database:postgresql:extensions"
|
|
338
562
|
# gives 2/3 = 0.67
|
|
339
|
-
score = info[:depth].to_f / info[:max_depth]
|
|
563
|
+
score = info[:depth].to_f / info[:max_depth]
|
|
340
564
|
best_score = [best_score, score].max
|
|
341
565
|
else
|
|
342
566
|
# Check if this tag is a parent of any extracted tag
|
|
343
567
|
tag_depth_map.each do |prefix, info|
|
|
344
568
|
if prefix.start_with?(tag + ':') || prefix == tag
|
|
345
|
-
score = tag.split(':').size.to_f / info[:max_depth]
|
|
569
|
+
score = tag.split(':').size.to_f / info[:max_depth]
|
|
346
570
|
best_score = [best_score, score].max
|
|
347
571
|
end
|
|
348
572
|
end
|
|
@@ -388,103 +612,51 @@ class HTM
|
|
|
388
612
|
# @return [Array<Hash>] Merged results sorted by RRF score
|
|
389
613
|
#
|
|
390
614
|
def merge_with_rrf(vector_results, fulltext_results, tag_results = [])
|
|
391
|
-
# Build RRF scores
|
|
392
|
-
# Key: node_id, Value: { node_data:, rrf_score:, sources: }
|
|
393
615
|
merged = {}
|
|
616
|
+
vector_results.each_with_index { |r, i| merge_vector_rrf_entry(merged, r, i + 1) }
|
|
617
|
+
fulltext_results.each_with_index { |r, i| merge_fulltext_rrf_entry(merged, r, i + 1) }
|
|
618
|
+
tag_results.each_with_index { |r, i| merge_tag_rrf_entry(merged, r, i + 1) }
|
|
619
|
+
merged.values.sort_by { |r| -r['rrf_score'] }
|
|
620
|
+
end
|
|
394
621
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
merged[id] = {
|
|
402
|
-
'id' => result['id'],
|
|
403
|
-
'content' => result['content'],
|
|
404
|
-
'access_count' => result['access_count'],
|
|
405
|
-
'created_at' => result['created_at'],
|
|
406
|
-
'token_count' => result['token_count'],
|
|
407
|
-
'similarity' => result['similarity'],
|
|
408
|
-
'text_rank' => 0.0,
|
|
409
|
-
'tag_depth_score' => 0.0,
|
|
410
|
-
'matched_tags' => [],
|
|
411
|
-
'rrf_score' => rrf_contribution,
|
|
412
|
-
'vector_rank' => rank,
|
|
413
|
-
'fulltext_rank' => nil,
|
|
414
|
-
'tag_rank' => nil,
|
|
415
|
-
'sources' => ['vector']
|
|
416
|
-
}
|
|
417
|
-
end
|
|
622
|
+
def merge_vector_rrf_entry(merged, result, rank)
|
|
623
|
+
merged[result['id']] = init_rrf_entry(result, rank).merge(
|
|
624
|
+
'similarity' => result['similarity'], 'vector_rank' => rank, 'sources' => ['vector']
|
|
625
|
+
)
|
|
626
|
+
end
|
|
418
627
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
else
|
|
432
|
-
# Node only in fulltext
|
|
433
|
-
merged[id] = {
|
|
434
|
-
'id' => result['id'],
|
|
435
|
-
'content' => result['content'],
|
|
436
|
-
'access_count' => result['access_count'],
|
|
437
|
-
'created_at' => result['created_at'],
|
|
438
|
-
'token_count' => result['token_count'],
|
|
439
|
-
'similarity' => 0.0,
|
|
440
|
-
'text_rank' => result['text_rank'],
|
|
441
|
-
'tag_depth_score' => 0.0,
|
|
442
|
-
'matched_tags' => [],
|
|
443
|
-
'rrf_score' => rrf_contribution,
|
|
444
|
-
'vector_rank' => nil,
|
|
445
|
-
'fulltext_rank' => rank,
|
|
446
|
-
'tag_rank' => nil,
|
|
447
|
-
'sources' => ['fulltext']
|
|
448
|
-
}
|
|
449
|
-
end
|
|
628
|
+
def merge_fulltext_rrf_entry(merged, result, rank)
|
|
629
|
+
rrf = 1.0 / (RRF_K + rank)
|
|
630
|
+
id = result['id']
|
|
631
|
+
if merged.key?(id)
|
|
632
|
+
merged[id]['rrf_score'] += rrf
|
|
633
|
+
merged[id]['text_rank'] = result['text_rank']
|
|
634
|
+
merged[id]['fulltext_rank'] = rank
|
|
635
|
+
merged[id]['sources'] << 'fulltext'
|
|
636
|
+
else
|
|
637
|
+
merged[id] = init_rrf_entry(result, rank).merge(
|
|
638
|
+
'text_rank' => result['text_rank'], 'fulltext_rank' => rank, 'sources' => ['fulltext']
|
|
639
|
+
)
|
|
450
640
|
end
|
|
641
|
+
end
|
|
451
642
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
'id' => result['id'],
|
|
469
|
-
'content' => result['content'],
|
|
470
|
-
'access_count' => result['access_count'],
|
|
471
|
-
'created_at' => result['created_at'],
|
|
472
|
-
'token_count' => result['token_count'],
|
|
473
|
-
'similarity' => 0.0,
|
|
474
|
-
'text_rank' => 0.0,
|
|
475
|
-
'tag_depth_score' => result['tag_depth_score'],
|
|
476
|
-
'matched_tags' => result['matched_tags'],
|
|
477
|
-
'rrf_score' => rrf_contribution,
|
|
478
|
-
'vector_rank' => nil,
|
|
479
|
-
'fulltext_rank' => nil,
|
|
480
|
-
'tag_rank' => rank,
|
|
481
|
-
'sources' => ['tags']
|
|
482
|
-
}
|
|
483
|
-
end
|
|
643
|
+
def merge_tag_rrf_entry(merged, result, rank)
|
|
644
|
+
rrf = 1.0 / (RRF_K + rank)
|
|
645
|
+
id = result['id']
|
|
646
|
+
if merged.key?(id)
|
|
647
|
+
merged[id]['rrf_score'] += rrf
|
|
648
|
+
merged[id]['tag_depth_score'] = result['tag_depth_score']
|
|
649
|
+
merged[id]['matched_tags'] = result['matched_tags']
|
|
650
|
+
merged[id]['tag_rank'] = rank
|
|
651
|
+
merged[id]['sources'] << 'tags'
|
|
652
|
+
else
|
|
653
|
+
merged[id] = init_rrf_entry(result, rank).merge(
|
|
654
|
+
'tag_depth_score' => result['tag_depth_score'],
|
|
655
|
+
'matched_tags' => result['matched_tags'],
|
|
656
|
+
'tag_rank' => rank,
|
|
657
|
+
'sources' => ['tags']
|
|
658
|
+
)
|
|
484
659
|
end
|
|
485
|
-
|
|
486
|
-
# Sort by RRF score descending
|
|
487
|
-
merged.values.sort_by { |r| -r['rrf_score'] }
|
|
488
660
|
end
|
|
489
661
|
end
|
|
490
662
|
end
|
|
@@ -24,7 +24,7 @@ class HTM
|
|
|
24
24
|
# @return [Hash] { node_id:, is_new:, robot_node: }
|
|
25
25
|
# @raise [ArgumentError] If metadata is not a Hash
|
|
26
26
|
#
|
|
27
|
-
def add(content:, token_count: 0,
|
|
27
|
+
def add(content:, robot_id:, token_count: 0, embedding: nil, metadata: {})
|
|
28
28
|
# Validate metadata parameter
|
|
29
29
|
unless metadata.is_a?(Hash)
|
|
30
30
|
raise ArgumentError, "metadata must be a Hash, got #{metadata.class}"
|
|
@@ -169,7 +169,7 @@ class HTM
|
|
|
169
169
|
# @return [Boolean] True if node exists
|
|
170
170
|
#
|
|
171
171
|
def exists?(node_id)
|
|
172
|
-
HTM::Models::Node.where(id: node_id).
|
|
172
|
+
HTM::Models::Node.where(id: node_id).any?
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
# Mark nodes as evicted from working memory
|