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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +2 -3
  3. data/.rubocop.yml +184 -0
  4. data/CHANGELOG.md +46 -0
  5. data/README.md +2 -0
  6. data/Rakefile +93 -12
  7. data/db/migrate/00008_create_node_relationships.rb +54 -0
  8. data/db/migrate/00009_fix_node_relationships_column_types.rb +17 -0
  9. data/db/schema.sql +124 -1
  10. data/docs/api/database.md +35 -57
  11. data/docs/api/embedding-service.md +1 -1
  12. data/docs/api/index.md +26 -15
  13. data/docs/api/working-memory.md +8 -8
  14. data/docs/architecture/index.md +5 -7
  15. data/docs/architecture/overview.md +5 -8
  16. data/docs/assets/images/htm-architecture-overview.svg +1 -1
  17. data/docs/assets/images/htm-context-assembly-flow.svg +2 -2
  18. data/docs/assets/images/htm-layered-architecture.svg +3 -3
  19. data/docs/assets/images/two-tier-memory-architecture.svg +1 -1
  20. data/docs/database/README.md +1 -0
  21. data/docs/database_rake_tasks.md +20 -28
  22. data/docs/development/contributing.md +5 -5
  23. data/docs/development/index.md +4 -7
  24. data/docs/development/schema.md +71 -1
  25. data/docs/development/setup.md +40 -82
  26. data/docs/development/testing.md +1 -1
  27. data/docs/examples/file-loading.md +4 -4
  28. data/docs/examples/mcp-client.md +1 -1
  29. data/docs/getting-started/quick-start.md +4 -4
  30. data/docs/guides/adding-memories.md +14 -1
  31. data/docs/guides/configuration.md +5 -5
  32. data/docs/guides/context-assembly.md +4 -4
  33. data/docs/guides/file-loading.md +12 -12
  34. data/docs/guides/getting-started.md +2 -2
  35. data/docs/guides/long-term-memory.md +7 -27
  36. data/docs/guides/propositions.md +20 -19
  37. data/docs/guides/recalling-memories.md +5 -5
  38. data/docs/guides/tags.md +18 -13
  39. data/docs/multi_framework_support.md +1 -1
  40. data/docs/robots/hive-mind.md +1 -1
  41. data/docs/robots/multi-robot.md +2 -2
  42. data/docs/robots/robot-groups.md +1 -1
  43. data/docs/robots/two-tier-memory.md +72 -94
  44. data/docs/setup_local_database.md +8 -54
  45. data/docs/using_rake_tasks_in_your_app.md +6 -6
  46. data/examples/01_basic_usage.rb +1 -0
  47. data/examples/03_custom_llm_configuration.rb +1 -0
  48. data/examples/04_file_loader_usage.rb +1 -0
  49. data/examples/05_timeframe_demo.rb +1 -0
  50. data/examples/06_example_app/app.rb +1 -0
  51. data/examples/07_cli_app/htm_cli.rb +1 -0
  52. data/examples/09_mcp_client.rb +1 -0
  53. data/examples/10_telemetry/demo.rb +1 -0
  54. data/examples/11_robot_groups/multi_process.rb +1 -0
  55. data/examples/11_robot_groups/same_process.rb +1 -0
  56. data/examples/12_rails_app/.envrc +12 -0
  57. data/examples/12_rails_app/Gemfile +8 -3
  58. data/examples/12_rails_app/Gemfile.lock +94 -89
  59. data/examples/12_rails_app/README.md +70 -19
  60. data/examples/12_rails_app/app/controllers/application_controller.rb +6 -0
  61. data/examples/12_rails_app/app/controllers/chats_controller.rb +305 -0
  62. data/examples/12_rails_app/app/controllers/dashboard_controller.rb +3 -0
  63. data/examples/12_rails_app/app/controllers/files_controller.rb +17 -2
  64. data/examples/12_rails_app/app/controllers/home_controller.rb +8 -0
  65. data/examples/12_rails_app/app/controllers/memories_controller.rb +9 -4
  66. data/examples/12_rails_app/app/controllers/messages_controller.rb +214 -0
  67. data/examples/12_rails_app/app/controllers/robots_controller.rb +11 -1
  68. data/examples/12_rails_app/app/controllers/tags_controller.rb +14 -1
  69. data/examples/12_rails_app/app/javascript/application.js +1 -1
  70. data/examples/12_rails_app/app/models/application_record.rb +5 -0
  71. data/examples/12_rails_app/app/models/chat.rb +36 -0
  72. data/examples/12_rails_app/app/models/message.rb +5 -0
  73. data/examples/12_rails_app/app/models/model.rb +5 -0
  74. data/examples/12_rails_app/app/models/tool_call.rb +5 -0
  75. data/examples/12_rails_app/app/views/chats/index.html.erb +61 -0
  76. data/examples/12_rails_app/app/views/chats/show.html.erb +213 -0
  77. data/examples/12_rails_app/app/views/dashboard/index.html.erb +3 -0
  78. data/examples/12_rails_app/app/views/files/index.html.erb +10 -5
  79. data/examples/12_rails_app/app/views/files/new.html.erb +4 -2
  80. data/examples/12_rails_app/app/views/files/show.html.erb +19 -3
  81. data/examples/12_rails_app/app/views/home/index.html.erb +45 -0
  82. data/examples/12_rails_app/app/views/layouts/application.html.erb +20 -18
  83. data/examples/12_rails_app/app/views/memories/_memory_card.html.erb +1 -1
  84. data/examples/12_rails_app/app/views/memories/deleted.html.erb +3 -1
  85. data/examples/12_rails_app/app/views/memories/edit.html.erb +2 -0
  86. data/examples/12_rails_app/app/views/memories/index.html.erb +2 -0
  87. data/examples/12_rails_app/app/views/memories/new.html.erb +2 -0
  88. data/examples/12_rails_app/app/views/memories/show.html.erb +4 -2
  89. data/examples/12_rails_app/app/views/messages/_message.html.erb +20 -0
  90. data/examples/12_rails_app/app/views/robots/index.html.erb +2 -0
  91. data/examples/12_rails_app/app/views/robots/new.html.erb +2 -0
  92. data/examples/12_rails_app/app/views/robots/show.html.erb +2 -0
  93. data/examples/12_rails_app/app/views/search/index.html.erb +59 -8
  94. data/examples/12_rails_app/app/views/shared/_navbar.html.erb +75 -29
  95. data/examples/12_rails_app/app/views/tags/index.html.erb +2 -0
  96. data/examples/12_rails_app/app/views/tags/show.html.erb +3 -1
  97. data/examples/12_rails_app/config/application.rb +1 -1
  98. data/examples/12_rails_app/config/database.yml +9 -5
  99. data/examples/12_rails_app/config/importmap.rb +1 -1
  100. data/examples/12_rails_app/config/initializers/htm.rb +9 -2
  101. data/examples/12_rails_app/config/initializers/ruby_llm.rb +33 -0
  102. data/examples/12_rails_app/config/routes.rb +39 -23
  103. data/examples/12_rails_app/db/migrate/20250124000001_create_ruby_llm_tables.rb +34 -0
  104. data/examples/12_rails_app/db/migrate/20250124000002_create_models_table.rb +28 -0
  105. data/examples/12_rails_app/db/schema.rb +67 -0
  106. data/examples/examples_helper.rb +25 -0
  107. data/lib/htm/circuit_breaker.rb +5 -6
  108. data/lib/htm/config/builder.rb +12 -12
  109. data/lib/htm/config/database.rb +21 -27
  110. data/lib/htm/config/defaults.yml +25 -13
  111. data/lib/htm/config/validator.rb +12 -18
  112. data/lib/htm/config.rb +93 -173
  113. data/lib/htm/database.rb +193 -199
  114. data/lib/htm/embedding_service.rb +4 -9
  115. data/lib/htm/integrations/sinatra.rb +7 -7
  116. data/lib/htm/job_adapter.rb +14 -21
  117. data/lib/htm/jobs/generate_embedding_job.rb +28 -44
  118. data/lib/htm/jobs/generate_propositions_job.rb +29 -55
  119. data/lib/htm/jobs/generate_relationships_job.rb +137 -0
  120. data/lib/htm/jobs/generate_tags_job.rb +45 -67
  121. data/lib/htm/loaders/markdown_loader.rb +65 -112
  122. data/lib/htm/long_term_memory/fulltext_search.rb +1 -1
  123. data/lib/htm/long_term_memory/hybrid_search.rb +300 -128
  124. data/lib/htm/long_term_memory/node_operations.rb +2 -2
  125. data/lib/htm/long_term_memory/relevance_scorer.rb +100 -68
  126. data/lib/htm/long_term_memory/tag_operations.rb +87 -120
  127. data/lib/htm/long_term_memory/vector_search.rb +1 -1
  128. data/lib/htm/long_term_memory.rb +2 -1
  129. data/lib/htm/mcp/cli.rb +59 -58
  130. data/lib/htm/mcp/server.rb +5 -6
  131. data/lib/htm/mcp/tools.rb +30 -36
  132. data/lib/htm/migration.rb +10 -10
  133. data/lib/htm/models/node.rb +2 -3
  134. data/lib/htm/models/node_relationship.rb +72 -0
  135. data/lib/htm/models/node_tag.rb +2 -2
  136. data/lib/htm/models/robot_node.rb +2 -2
  137. data/lib/htm/models/tag.rb +41 -28
  138. data/lib/htm/observability.rb +45 -51
  139. data/lib/htm/proposition_service.rb +3 -7
  140. data/lib/htm/query_cache.rb +13 -15
  141. data/lib/htm/railtie.rb +1 -2
  142. data/lib/htm/robot_group.rb +9 -9
  143. data/lib/htm/sequel_config.rb +1 -0
  144. data/lib/htm/sql_builder.rb +1 -1
  145. data/lib/htm/tag_service.rb +2 -6
  146. data/lib/htm/timeframe.rb +4 -5
  147. data/lib/htm/timeframe_extractor.rb +42 -83
  148. data/lib/htm/version.rb +1 -1
  149. data/lib/htm/workflows/remember_workflow.rb +112 -115
  150. data/lib/htm/working_memory.rb +21 -26
  151. data/lib/htm.rb +103 -116
  152. data/lib/tasks/db.rake +0 -2
  153. data/lib/tasks/doc.rake +14 -13
  154. data/lib/tasks/files.rake +5 -12
  155. data/lib/tasks/htm.rake +70 -71
  156. data/lib/tasks/jobs.rake +41 -47
  157. data/lib/tasks/tags.rake +3 -8
  158. metadata +28 -106
  159. data/lib/htm/config/section.rb +0 -74
  160. data/lib/htm/loaders/defaults_loader.rb +0 -166
  161. 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 = [[limit.to_i, 1].max, MAX_HYBRID_LIMIT].min
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
- # Hybrid search using Reciprocal Rank Fusion
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
- # Runs vector, fulltext, and tag searches independently, then merges
72
- # results using RRF scoring. Nodes appearing in multiple searches
73
- # get contributions from each, naturally boosting them.
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
- # Run all three searches independently
85
- vector_results = fetch_vector_candidates(
86
- query: query,
87
- embedding_service: embedding_service,
88
- timeframe: timeframe,
89
- metadata: metadata,
90
- limit: candidate_limit
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
- fulltext_results = fetch_fulltext_candidates(
94
- query: query,
95
- timeframe: timeframe,
96
- metadata: metadata,
97
- limit: candidate_limit
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
- # Extract tags from query and find matching nodes
101
- tag_results = fetch_tag_candidates(
102
- query: query,
103
- timeframe: timeframe,
104
- metadata: metadata,
105
- limit: candidate_limit
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
- # Merge using RRF
109
- merged = merge_with_rrf(vector_results, fulltext_results, tag_results)
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
- # Take top results
112
- top_results = merged.first(limit)
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
- # Track access for retrieved nodes
115
- node_ids = top_results.map { |r| r['id'] }
116
- track_access(node_ids)
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
- top_results
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
- # Note: Using Sequel.lit for the vector comparison since it needs special handling
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.sort_by { |r| -r['tag_depth_score'] }
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].to_f
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].to_f
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
- # Process vector results
396
- vector_results.each_with_index do |result, index|
397
- id = result['id']
398
- rank = index + 1 # 1-based rank
399
- rrf_contribution = 1.0 / (RRF_K + rank)
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
- # Process fulltext results
420
- fulltext_results.each_with_index do |result, index|
421
- id = result['id']
422
- rank = index + 1 # 1-based rank
423
- rrf_contribution = 1.0 / (RRF_K + rank)
424
-
425
- if merged.key?(id)
426
- # Node appears in both - add RRF contribution (this is the boost!)
427
- merged[id]['rrf_score'] += rrf_contribution
428
- merged[id]['text_rank'] = result['text_rank']
429
- merged[id]['fulltext_rank'] = rank
430
- merged[id]['sources'] << 'fulltext'
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
- # Process tag results
453
- tag_results.each_with_index do |result, index|
454
- id = result['id']
455
- rank = index + 1 # 1-based rank
456
- rrf_contribution = 1.0 / (RRF_K + rank)
457
-
458
- if merged.key?(id)
459
- # Node already found - add RRF contribution (boost!)
460
- merged[id]['rrf_score'] += rrf_contribution
461
- merged[id]['tag_depth_score'] = result['tag_depth_score']
462
- merged[id]['matched_tags'] = result['matched_tags']
463
- merged[id]['tag_rank'] = rank
464
- merged[id]['sources'] << 'tags'
465
- else
466
- # Node only found via tags
467
- merged[id] = {
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, robot_id:, embedding: nil, metadata: {})
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).count > 0
172
+ HTM::Models::Node.where(id: node_id).any?
173
173
  end
174
174
 
175
175
  # Mark nodes as evicted from working memory