smart_rag 0.1.0 → 0.2.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +252 -0
  3. data/.rspec +2 -0
  4. data/AGENTS.md +33 -0
  5. data/API_DOCUMENTATION.md +828 -0
  6. data/CHANGELOG.md +11 -1
  7. data/ER-diagram.mmd +144 -0
  8. data/Gemfile +50 -0
  9. data/Gemfile.lock +381 -0
  10. data/Hybrid_Reranking.md +171 -0
  11. data/README.en.md +420 -28
  12. data/README.md +534 -63
  13. data/Rakefile +268 -0
  14. data/SETUP_GUIDE.md +650 -0
  15. data/SmartChunking.md +180 -0
  16. data/USAGE_EXAMPLES.md +1002 -0
  17. data/config/llm_config.yml +4 -2
  18. data/config/smart_rag.yml +45 -1
  19. data/config.ru +15 -0
  20. data/db/migrations/006_create_text_search_configs.rb +3 -2
  21. data/db/migrations/008_create_embeddings.rb +5 -4
  22. data/db/migrations/012_add_metadata_to_source_sections.rb +11 -0
  23. data/db/migrations/013_create_media_jobs.rb +25 -0
  24. data/db/migrations/014_add_media_job_operations_indexes.rb +11 -0
  25. data/db/migrations/015_add_media_leases_and_objects.rb +80 -0
  26. data/db/migrations/016_add_document_principals_and_staging_references.rb +38 -0
  27. data/db/migrations/017_add_media_job_request_fingerprint.rb +48 -0
  28. data/db/seeds/text_search_configs.sql +3 -3
  29. data/design.md +1057 -0
  30. data/docs/API_DOCUMENTATION.md +838 -0
  31. data/docs/DOCUMENTATION_INDEX.en.md +60 -0
  32. data/docs/DOCUMENTATION_INDEX.md +65 -0
  33. data/docs/FIX_SUMMARY.md +256 -0
  34. data/docs/FIX_SUMMARY_COMPLETE.md +273 -0
  35. data/docs/Hybrid_Reranking.md +171 -0
  36. data/docs/MIGRATION_GUIDE.md +151 -0
  37. data/docs/PERFORMANCE_GUIDE.md +58 -0
  38. data/docs/SETUP_GUIDE.md +659 -0
  39. data/docs/SmartChunking.md +180 -0
  40. data/docs/USAGE_EXAMPLES.md +1008 -0
  41. data/docs/design.md +1057 -0
  42. data/docs/evidence_pack.md +211 -0
  43. data/docs/requirements.md +376 -0
  44. data/docs/retrieval_plan.md +251 -0
  45. data/docs/smartrag_improvement_plan.md +201 -0
  46. data/docs/smartrag_refactor.md +216 -0
  47. data/docs/todo.md +931 -0
  48. data/examples/common.rb +1 -1
  49. data/exe/smart-rag-db +163 -0
  50. data/exe/smart-rag-media-worker +34 -0
  51. data/lib/smart_rag/config.rb +12 -0
  52. data/lib/smart_rag/core/document_processor.rb +80 -16
  53. data/lib/smart_rag/core/local_content_store.rb +51 -0
  54. data/lib/smart_rag/core/media_extractors.rb +140 -0
  55. data/lib/smart_rag/core/media_job_queue.rb +353 -0
  56. data/lib/smart_rag/core/media_metadata_extractor.rb +188 -0
  57. data/lib/smart_rag/core/media_object_registry.rb +79 -0
  58. data/lib/smart_rag/core/media_processor.rb +228 -0
  59. data/lib/smart_rag/core/media_safety_policy.rb +61 -0
  60. data/lib/smart_rag/core/s3_content_store.rb +78 -0
  61. data/lib/smart_rag/core/transcript_normalizer.rb +44 -0
  62. data/lib/smart_rag/core/video_semantic_extractor.rb +130 -0
  63. data/lib/smart_rag/http_access_policy.rb +86 -0
  64. data/lib/smart_rag/http_app.rb +188 -0
  65. data/lib/smart_rag/models/embedding.rb +1 -1
  66. data/lib/smart_rag/models/research_topic.rb +1 -1
  67. data/lib/smart_rag/models/research_topic_section.rb +5 -0
  68. data/lib/smart_rag/models/research_topic_tag.rb +5 -0
  69. data/lib/smart_rag/models/search_log.rb +1 -1
  70. data/lib/smart_rag/models/section_fts.rb +5 -0
  71. data/lib/smart_rag/models/section_tag.rb +5 -0
  72. data/lib/smart_rag/models/source_document.rb +1 -1
  73. data/lib/smart_rag/models/source_section.rb +1 -1
  74. data/lib/smart_rag/models/tag.rb +1 -1
  75. data/lib/smart_rag/models/text_search_config.rb +5 -0
  76. data/lib/smart_rag/retrieve.rb +72 -1
  77. data/lib/smart_rag/services/embedding_service.rb +1 -1
  78. data/lib/smart_rag/services/fulltext_search_service.rb +11 -13
  79. data/lib/smart_rag/services/hybrid_search_service.rb +15 -11
  80. data/lib/smart_rag/services/summarization_service.rb +1 -1
  81. data/lib/smart_rag/services/tag_service.rb +1 -1
  82. data/lib/smart_rag/version.rb +1 -1
  83. data/lib/smart_rag.rb +264 -30
  84. data/patch_language.rb +27 -0
  85. data/requirements.md +376 -0
  86. data/source_documents_export.json +11072 -0
  87. data/todo.md +931 -0
  88. data/workers/analyze_content.rb +6 -2
  89. data/workers/get_embedding.rb +1 -1
  90. metadata +151 -38
data/lib/smart_rag.rb CHANGED
@@ -7,6 +7,7 @@ require "sequel"
7
7
  require "logger"
8
8
  require "digest"
9
9
  require "json"
10
+ require "uri"
10
11
 
11
12
  module SmartRAG
12
13
  class Error < StandardError; end
@@ -64,16 +65,105 @@ module SmartRAG
64
65
 
65
66
  # Add document to knowledge base
66
67
  def add_document(document_path, options = {})
67
- result = @document_processor.create_document(document_path, options)
68
+ document_metadata = symbolize_hash(options[:metadata] || {}).merge(media_type: 'document', schema_version: 1)
69
+ result = @document_processor.create_document(document_path, options.merge(metadata: document_metadata))
68
70
  {
69
71
  document_id: result[:document].id,
72
+ media_type: 'document',
70
73
  section_count: result[:sections].length,
71
74
  status: "success",
75
+ metadata: normalize_document_metadata(result[:document].metadata),
76
+ warnings: []
72
77
  }
73
78
  end
74
79
 
80
+ def add_media(source, options = {})
81
+ require_relative 'smart_rag/core/media_processor'
82
+ require_relative 'smart_rag/core/media_metadata_extractor'
83
+ requested_type = options[:media_type]
84
+ requested_type = nil if requested_type.to_s == 'auto'
85
+ detection_source = source.to_s.match?(%r{\Ahttps?://}) ? URI.parse(source.to_s).path : source
86
+ detected_type = requested_type || ::SmartRAG::Core::MediaMetadataExtractor.new.detect_media_type(detection_source)
87
+ return add_document(source, options) if detected_type.to_s == 'document'
88
+
89
+ @media_processor ||= build_media_processor
90
+ @media_processor.create(source, options)
91
+ end
92
+
93
+ def add_image(source, options = {})
94
+ add_media(source, options.merge(media_type: 'image'))
95
+ end
96
+
97
+ def add_audio(source, options = {})
98
+ add_media(source, options.merge(media_type: 'audio'))
99
+ end
100
+
101
+ def add_video(source, options = {})
102
+ add_media(source, options.merge(media_type: 'video'))
103
+ end
104
+
105
+ def enqueue_media(source, options = {})
106
+ job_options = options.dup
107
+ operation = job_options.delete(:operation) || :add_media
108
+ max_attempts = job_options.delete(:max_attempts) || @config.dig(:media, :async, :max_attempts) || 3
109
+ idempotency_key = job_options.delete(:idempotency_key)
110
+ principal = job_options.delete(:principal) || 'system'
111
+ staging_media_object_id = job_options.delete(:staging_media_object_id)
112
+ queued = media_job_queue.enqueue(operation: operation, source: source, options: job_options,
113
+ max_attempts: max_attempts, idempotency_key: idempotency_key,
114
+ principal: principal, staging_media_object_id: staging_media_object_id)
115
+ { job_id: queued[:id], status: queued[:status], media_type: job_options[:media_type],
116
+ deduplicated: queued[:deduplicated] == true, warnings: [] }
117
+ end
118
+
119
+ def stage_media_upload(source)
120
+ store = media_content_store
121
+ return nil unless store
122
+ stored = store.put(source)
123
+ object_id = media_object_registry.register(stored, byte_size: File.size(source))
124
+ stored.merge(media_object_id: object_id)
125
+ end
126
+
127
+ def media_job(job_id, principal: nil)
128
+ media_job_queue.job(job_id, principal: principal)
129
+ end
130
+
131
+ def media_jobs(status: nil, limit: 20, offset: 0, principal: nil)
132
+ media_job_queue.list(status: status, limit: limit, offset: offset, principal: principal)
133
+ end
134
+
135
+ def cancel_media_job(job_id, principal: nil)
136
+ media_job_queue.cancel(job_id, principal: principal)
137
+ end
138
+
139
+ def retry_media_job(job_id, principal: nil)
140
+ media_job_queue.retry_job(job_id, principal: principal)
141
+ end
142
+
143
+ def media_job_statistics(principal: nil)
144
+ media_job_queue.statistics(stale_after_seconds: media_async_config.fetch(:stale_after_seconds, 900),
145
+ principal: principal)
146
+ end
147
+
148
+ def recover_media_jobs
149
+ media_job_queue.recover_stale(timeout_seconds: media_async_config.fetch(:stale_after_seconds, 900))
150
+ end
151
+
152
+ def prune_media_jobs
153
+ media_job_queue.prune(retention_seconds: media_async_config.fetch(:retention_seconds, 604_800))
154
+ end
155
+
156
+ def garbage_collect_media_objects(limit: 100)
157
+ return { removed_count: 0, object_ids: [], disabled: true } unless media_object_registry
158
+ media_object_registry.garbage_collect(limit: limit)
159
+ end
160
+
161
+ def run_media_jobs(limit: 1)
162
+ media_job_queue.run(limit: limit)
163
+ end
164
+
75
165
  # Remove document from knowledge base
76
- def remove_document(document_id)
166
+ def remove_document(document_id, principal: nil)
77
167
  return { success: false, deleted_sections: 0, deleted_embeddings: 0 } unless document_id.to_s =~ /\A-?\d+\Z/
78
168
 
79
169
  doc_id_i = document_id.to_i
@@ -81,20 +171,25 @@ module SmartRAG
81
171
  result = nil
82
172
 
83
173
  @delete_mutex.synchronize do
84
- doc = ::SmartRAG::Models::SourceDocument[doc_id_i]
85
- if doc.nil?
86
- result = { success: false, deleted_sections: 0, deleted_embeddings: 0 }
87
- else
88
- section_ids = ::SmartRAG::Models::SourceSection.where(document_id: doc_id_i).select_map(:id)
89
- deleted_embeddings = section_ids.any? ? ::SmartRAG::Models::Embedding.where(source_id: section_ids).delete : 0
90
- deleted_sections = ::SmartRAG::Models::SourceSection.where(document_id: doc_id_i).delete
91
- deleted = ::SmartRAG::Models::SourceDocument.where(id: doc_id_i).delete
92
-
93
- result = {
94
- success: deleted > 0,
95
- deleted_sections: deleted_sections,
96
- deleted_embeddings: deleted_embeddings,
97
- }
174
+ ::SmartRAG.db.transaction do
175
+ document_scope = ::SmartRAG::Models::SourceDocument.where(id: doc_id_i)
176
+ document_scope = document_scope.where(principal: principal.to_s) if principal
177
+ doc = document_scope.first
178
+ if doc.nil?
179
+ result = { success: false, deleted_sections: 0, deleted_embeddings: 0 }
180
+ else
181
+ media_object_registry&.detach_document(doc_id_i)
182
+ section_ids = ::SmartRAG::Models::SourceSection.where(document_id: doc_id_i).select_map(:id)
183
+ deleted_embeddings = section_ids.any? ? ::SmartRAG::Models::Embedding.where(source_id: section_ids).delete : 0
184
+ deleted_sections = ::SmartRAG::Models::SourceSection.where(document_id: doc_id_i).delete
185
+ deleted = ::SmartRAG::Models::SourceDocument.where(id: doc_id_i).delete
186
+
187
+ result = {
188
+ success: deleted > 0,
189
+ deleted_sections: deleted_sections,
190
+ deleted_embeddings: deleted_embeddings,
191
+ }
192
+ end
98
193
  end
99
194
  end
100
195
 
@@ -105,10 +200,12 @@ module SmartRAG
105
200
  end
106
201
 
107
202
  # Get document information
108
- def get_document(document_id)
203
+ def get_document(document_id, principal: nil)
109
204
  return nil unless document_id.to_s =~ /\A-?\d+\Z/
110
205
 
111
- document = ::SmartRAG::Models::SourceDocument[document_id.to_i]
206
+ dataset = ::SmartRAG::Models::SourceDocument.where(id: document_id.to_i)
207
+ dataset = dataset.where(principal: principal.to_s) if principal
208
+ document = dataset.first
112
209
  return nil unless document
113
210
 
114
211
  {
@@ -148,6 +245,7 @@ module SmartRAG
148
245
  per_page = [[per_page, 1].max, 100].min
149
246
 
150
247
  dataset = ::SmartRAG::Models::SourceDocument.dataset
248
+ dataset = dataset.where(principal: options[:principal].to_s) if options[:principal]
151
249
 
152
250
  if options[:search] && !options[:search].empty?
153
251
  search_term = "%#{options[:search]}%"
@@ -246,17 +344,21 @@ module SmartRAG
246
344
  end
247
345
 
248
346
  search_type = (options[:search_type] || "hybrid").to_s
249
-
250
- case search_type
251
- when "hybrid"
252
- hybrid_search(normalized_query, options.merge(search_type: :hybrid))
253
- when "vector"
254
- vector_search(normalized_query, options.merge(search_type: :vector))
255
- when "fulltext"
256
- fulltext_search(normalized_query, options.merge(search_type: :fulltext))
257
- else
258
- raise ArgumentError, "Invalid search_type: #{search_type}. Must be 'hybrid', 'vector', or 'fulltext'"
259
- end
347
+ options = scope_search_options(options)
348
+ return empty_search_response(normalized_query, search_type) if options.delete(:principal_scope_empty)
349
+
350
+ response = case search_type
351
+ when "hybrid"
352
+ hybrid_search(normalized_query, options.merge(search_type: :hybrid))
353
+ when "vector"
354
+ vector_search(normalized_query, options.merge(search_type: :vector))
355
+ when "fulltext"
356
+ fulltext_search(normalized_query, options.merge(search_type: :fulltext))
357
+ else
358
+ raise ArgumentError, "Invalid search_type: #{search_type}. Must be 'hybrid', 'vector', or 'fulltext'"
359
+ end
360
+ filter_search_response(response, options[:document_ids]) if options[:principal]
361
+ response
260
362
  end
261
363
 
262
364
  # Structured retrieval interface for SmartBrain integration.
@@ -726,7 +828,8 @@ module SmartRAG
726
828
  end
727
829
 
728
830
  # Get system statistics
729
- def statistics
831
+ def statistics(principal: nil)
832
+ return scoped_statistics(principal) if principal
730
833
  {
731
834
  document_count: ::SmartRAG::Models::SourceDocument.count,
732
835
  section_count: ::SmartRAG::Models::SourceSection.count,
@@ -748,6 +851,135 @@ module SmartRAG
748
851
 
749
852
  private
750
853
 
854
+ def scope_search_options(options)
855
+ return options unless options[:principal]
856
+ allowed_ids = ::SmartRAG::Models::SourceDocument.where(principal: options[:principal].to_s).select_map(:id)
857
+ requested_ids = Array(options[:document_ids]).map(&:to_i)
858
+ allowed_ids &= requested_ids unless requested_ids.empty?
859
+ options.merge(document_ids: allowed_ids, principal_scope_empty: allowed_ids.empty?)
860
+ end
861
+
862
+ def empty_search_response(query, search_type)
863
+ { query: query, search_type: search_type.to_sym, results: [], total_results: 0,
864
+ metadata: { total_count: 0 } }
865
+ end
866
+
867
+ def filter_search_response(response, allowed_document_ids)
868
+ return response unless response.is_a?(Hash)
869
+ allowed = Array(allowed_document_ids).map(&:to_i)
870
+ key = response.key?(:results) ? :results : 'results'
871
+ results = Array(response[key]).select do |result|
872
+ section = result.is_a?(Hash) ? (result[:section] || result['section'] || result) : result
873
+ document_id = if result.is_a?(Hash)
874
+ result[:document_id] || result['document_id']
875
+ end
876
+ document_id ||= if section.is_a?(Hash)
877
+ section[:document_id] || section['document_id']
878
+ elsif section.respond_to?(:document_id)
879
+ section.document_id
880
+ end
881
+ document_id && allowed.include?(document_id.to_i)
882
+ end
883
+ response[key] = results
884
+ response[:total_results] = results.length if response.key?(:total_results)
885
+ response['total_results'] = results.length if response.key?('total_results')
886
+ if response[:metadata].is_a?(Hash)
887
+ response[:metadata][:total_count] = results.length
888
+ elsif response['metadata'].is_a?(Hash)
889
+ response['metadata']['total_count'] = results.length
890
+ end
891
+ response
892
+ end
893
+
894
+ def scoped_statistics(principal)
895
+ document_ids = ::SmartRAG::Models::SourceDocument.where(principal: principal.to_s).select_map(:id)
896
+ section_ids = ::SmartRAG::Models::SourceSection.where(document_id: document_ids).select_map(:id)
897
+ {
898
+ document_count: document_ids.length,
899
+ section_count: section_ids.length,
900
+ topic_count: ::SmartRAG.db[:research_topic_sections].where(section_id: section_ids).distinct.count(:research_topic_id),
901
+ tag_count: ::SmartRAG.db[:section_tags].where(section_id: section_ids).distinct.count(:tag_id),
902
+ embedding_count: ::SmartRAG::Models::Embedding.where(source_id: section_ids).count
903
+ }
904
+ end
905
+
906
+ def normalize_document_metadata(value)
907
+ parsed = value.is_a?(String) ? JSON.parse(value) : value
908
+ parsed.is_a?(Hash) ? parsed : {}
909
+ rescue JSON::ParserError
910
+ {}
911
+ end
912
+
913
+ def symbolize_hash(value)
914
+ return {} unless value.is_a?(Hash)
915
+
916
+ value.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
917
+ end
918
+
919
+ def media_job_queue
920
+ require_relative 'smart_rag/core/media_job_queue'
921
+ @media_job_queue ||= ::SmartRAG::Core::MediaJobQueue.new(
922
+ db: ::SmartRAG.db,
923
+ heartbeat_interval_seconds: media_async_config.fetch(:heartbeat_interval_seconds, 30),
924
+ handler: lambda do |operation, source, options|
925
+ raise ArgumentError, "unsupported media job operation: #{operation}" unless %i[add_document add_media add_image add_audio add_video].include?(operation)
926
+ options.delete(:delete_source_after)
927
+ public_send(operation, source, options)
928
+ end
929
+ )
930
+ end
931
+
932
+ def media_async_config
933
+ @config.dig(:media, :async) || {}
934
+ end
935
+
936
+ def build_media_processor
937
+ require_relative 'smart_rag/core/media_processor'
938
+ require_relative 'smart_rag/core/media_extractors'
939
+ require_relative 'smart_rag/core/media_safety_policy'
940
+ require_relative 'smart_rag/core/local_content_store'
941
+ require_relative 'smart_rag/core/s3_content_store'
942
+ require_relative 'smart_rag/core/media_object_registry'
943
+
944
+ media_config = @config[:media] || {}
945
+ store_config = media_config[:content_store] || {}
946
+ store = media_content_store
947
+ ::SmartRAG::Core::MediaProcessor.new(
948
+ document_processor: @document_processor,
949
+ safety_policy: ::SmartRAG::Core::MediaSafetyPolicy.new(media_config),
950
+ content_store: store,
951
+ object_registry: media_object_registry,
952
+ default_extractors: ::SmartRAG::Core::MediaExtractors::Factory.build(media_config),
953
+ logger: @logger
954
+ )
955
+ end
956
+
957
+ def media_content_store
958
+ return @media_content_store if defined?(@media_content_store)
959
+ require_relative 'smart_rag/core/local_content_store'
960
+ require_relative 'smart_rag/core/s3_content_store'
961
+ store_config = @config.dig(:media, :content_store) || {}
962
+ return @media_content_store = nil unless store_config.fetch(:enabled, false)
963
+
964
+ provider = store_config.fetch(:provider, 'local').to_s
965
+ @media_content_store = case provider
966
+ when 'local'
967
+ ::SmartRAG::Core::LocalContentStore.new(root: store_config.fetch(:root))
968
+ when 's3'
969
+ ::SmartRAG::Core::S3ContentStore.new(**store_config.reject { |key, _| %i[enabled provider root].include?(key) })
970
+ else
971
+ raise ArgumentError, "unsupported media content store provider: #{provider}"
972
+ end
973
+ end
974
+
975
+ def media_object_registry
976
+ return @media_object_registry if defined?(@media_object_registry)
977
+ store = media_content_store
978
+ return @media_object_registry = nil unless store
979
+ require_relative 'smart_rag/core/media_object_registry'
980
+ @media_object_registry = ::SmartRAG::Core::MediaObjectRegistry.new(db: ::SmartRAG.db, content_store: store)
981
+ end
982
+
751
983
  def initialize_db_connection
752
984
  db_config = @config[:database]
753
985
 
@@ -962,6 +1194,7 @@ module SmartRAG
962
1194
  error: error_message
963
1195
  }
964
1196
  },
1197
+ total_results: 0,
965
1198
  search_type: :fulltext
966
1199
  }
967
1200
  end
@@ -970,6 +1203,7 @@ module SmartRAG
970
1203
  {
971
1204
  query: query,
972
1205
  results: [],
1206
+ total_results: 0,
973
1207
  metadata: {
974
1208
  total_count: 0,
975
1209
  execution_time_ms: 0,
data/patch_language.rb ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # Quick patch script to fix the language detection issue
3
+
4
+ file_path = '/root/smart_rag/lib/smart_rag/services/hybrid_search_service.rb'
5
+ content = File.read(file_path)
6
+
7
+ # Find and replace the problematic line
8
+ # We want to add debug logging before the language line
9
+ old_text = ' @logger.info "Hybrid search: \'#{query}\', language: #{language}, limit: #{limit}, alpha: #{alpha}"'
10
+
11
+ if content.include?(old_text)
12
+ puts 'Found the text to replace!'
13
+
14
+ # Check if debug log is already there
15
+ debug_text = ' @logger.debug "HybridSearchService: language=#{language} (type: #{language.class}), options[:language]=#{options[:language].inspect}"'
16
+
17
+ if !content.include?(debug_text)
18
+ puts 'Adding debug log...'
19
+ new_content = content.sub(old_text, debug_text + "\n" + old_text)
20
+ File.write(file_path, new_content)
21
+ puts 'Done! Debug log added.'
22
+ else
23
+ puts 'Debug log already exists.'
24
+ end
25
+ else
26
+ puts 'Could not find the target text!'
27
+ end