From 1f79e835167c25e20dbd29d0d136270c58f24eba Mon Sep 17 00:00:00 2001 From: Jeremy Prevost Date: Thu, 25 Jun 2026 15:15:57 -0400 Subject: [PATCH 1/3] Adds semantic query tuning parameters Why are these changes being introduced: * We want to be able to tune the semantic query parameters for better results. Relevant ticket(s): * https://mitlibraries.atlassian.net/browse/USE-619 How does this address that need: * Introduces new semantic query tuning parameters to the GraphQL API. * Ensures those parameters are not documented publicly by prefixing them with `INTERNAL USE ONLY` in the description and skipping them in introspection queries. --- app/graphql/timdex_schema.rb | 22 ++++++++++++++++++++++ app/graphql/types/query_type.rb | 21 +++++++++++++++++++-- app/models/hybrid_query_builder.rb | 4 ++-- app/models/opensearch.rb | 10 ++++++++-- app/models/semantic_query_builder.rb | 22 +++++++++++++++++++--- 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/graphql/timdex_schema.rb b/app/graphql/timdex_schema.rb index 697f5ec..08f3369 100644 --- a/app/graphql/timdex_schema.rb +++ b/app/graphql/timdex_schema.rb @@ -3,4 +3,26 @@ class TimdexSchema < GraphQL::Schema trace_class(GraphQL::Tracing::LegacyTrace) query(Types::QueryType) + + use GraphQL::Schema::Visibility + + # Hide arguments marked as "INTERNAL USE ONLY" from introspection queries + def self.visible?(member, context) + # 1. Detect if the member is one of your internal arguments + if member.respond_to?(:description) && member.description&.include?('INTERNAL USE ONLY') + + # 2. Extract the root operation fields being requested + selected_fields = context.query&.selected_operation&.selections || [] + + # 3. Check if any root field starts with "__schema" or "__type" + is_introspection = selected_fields.any? do |selection| + selection.respond_to?(:name) && selection.name.start_with?('__') + end + + # 4. Hide the arguments if GraphiQL/Introspection is sniffing the schema + return false if is_introspection + end + + super + end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 4aff58b..2b6f20f 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -105,18 +105,35 @@ def record_id(id:, index:) argument :subjects_filter, [String], required: false, default_value: nil, description: 'Filter by subject terms. Use the `contentType` aggregation ' \ 'for a list of possible values. Multiple values are ANDed.' + + # Internal semantic query tuning parameters (not documented publicly). My start with `INTERNAL USE ONLY` to be + # excluded from public documentation. + argument :semantic_must_boost_threshold, Float, required: false, default_value: nil, + description: 'INTERNAL USE ONLY: Semantic query must boost threshold (0.0-1.0)' + argument :semantic_drop_boost_threshold, Float, required: false, default_value: nil, + description: 'INTERNAL USE ONLY: Semantic query drop boost threshold (0.0-1.0)' + argument :semantic_short_query_max_tokens, Integer, required: false, default_value: nil, + description: 'INTERNAL USE ONLY: Semantic query short query max tokens' end def search(searchterm:, citation:, contributors:, funding_information:, geodistance:, geobox:, identifiers:, locations:, subjects:, title:, index:, source:, from:, boolean_type:, fulltext:, per_page: 20, - query_mode: 'keyword', use_global_scoring: false, **filters) + query_mode: 'keyword', use_global_scoring: false, semantic_must_boost_threshold: nil, + semantic_drop_boost_threshold: nil, semantic_short_query_max_tokens: nil, **filters) query = construct_query(searchterm, citation, contributors, funding_information, geodistance, geobox, identifiers, locations, subjects, title, source, boolean_type, filters, per_page, query_mode) + semantic_options = { + must_boost_threshold: semantic_must_boost_threshold, + drop_boost_threshold: semantic_drop_boost_threshold, + short_query_max_tokens: semantic_short_query_max_tokens + } + results = Opensearch.new.search(from, query, Timdex::OSClient, highlight: highlight_requested?, index: index, fulltext: fulltext, query_mode: query_mode, requested_aggregations: requested_aggregations, - use_global_scoring: use_global_scoring) + use_global_scoring: use_global_scoring, + semantic_options: semantic_options) response = {} response[:hits] = results['hits']['total']['value'] diff --git a/app/models/hybrid_query_builder.rb b/app/models/hybrid_query_builder.rb index ddc4a88..4f791a8 100644 --- a/app/models/hybrid_query_builder.rb +++ b/app/models/hybrid_query_builder.rb @@ -1,5 +1,5 @@ class HybridQueryBuilder - def build(params, fulltext: false) + def build(params, fulltext: false, semantic_options: {}) query_text = params[:q].to_s.strip lexical_query = LexicalQueryBuilder.new.build(params, fulltext: fulltext) @@ -8,7 +8,7 @@ def build(params, fulltext: false) return lexical_query if query_text.blank? begin - semantic_query = SemanticQueryBuilder.new.build(params, fulltext: fulltext) + semantic_query = SemanticQueryBuilder.new.build(params, fulltext: fulltext, semantic_options: semantic_options) # Both succeeded - combine them with should clause while preserving filters combine_queries(semantic_query, lexical_query) diff --git a/app/models/opensearch.rb b/app/models/opensearch.rb index da0e413..8bd7c4e 100644 --- a/app/models/opensearch.rb +++ b/app/models/opensearch.rb @@ -4,12 +4,13 @@ class Opensearch MAX_SIZE = 200 def search(from, params, client, highlight: false, index: nil, fulltext: false, query_mode: 'keyword', - requested_aggregations: [], use_global_scoring: false) + requested_aggregations: [], use_global_scoring: false, semantic_options: {}) @params = params @highlight = highlight @fulltext = fulltext?(fulltext) @query_mode = query_mode @requested_aggregations = requested_aggregations + @semantic_options = semantic_options index = default_index unless index.present? search_params = { index:, body: build_query(from) } search_params[:search_type] = 'dfs_query_then_fetch' if use_global_scoring @@ -69,7 +70,12 @@ def query LexicalQueryBuilder.new end - builder.build(@params, fulltext: @fulltext) + # Only pass semantic_options to builders that support it (semantic and hybrid) + if @query_mode.in?(%w[semantic hybrid]) + builder.build(@params, fulltext: @fulltext, semantic_options: @semantic_options) + else + builder.build(@params, fulltext: @fulltext) + end end def sort_builder diff --git a/app/models/semantic_query_builder.rb b/app/models/semantic_query_builder.rb index dafffe3..bb039d6 100644 --- a/app/models/semantic_query_builder.rb +++ b/app/models/semantic_query_builder.rb @@ -2,7 +2,7 @@ class SemanticQueryBuilder # Dedicated exception for Lambda invocation failures (not parsing/validation errors) class LambdaError < StandardError; end - def build(params, fulltext: false) + def build(params, fulltext: false, semantic_options: {}) query_text = params[:q].to_s.strip # If no query text provided, return a match_all query with filters applied @@ -14,7 +14,8 @@ def build(params, fulltext: false) return { match_all: {} } end - lambda_response = invoke_semantic_builder(query_text) + lambda_response = invoke_semantic_builder(query_text, semantic_options) + Rails.logger.debug("SemanticQueryBuilder Lambda response: #{lambda_response.inspect}") semantic_query = parse_lambda_response(lambda_response) # Validate the query structure has a bool clause before applying filters @@ -31,8 +32,23 @@ def build(params, fulltext: false) private - def invoke_semantic_builder(query_text) + def invoke_semantic_builder(query_text, semantic_options = {}) payload = { query: query_text } + + # Add optional semantic tuning parameters if provided + if semantic_options[:must_boost_threshold].present? + payload[:must_boost_threshold] = + semantic_options[:must_boost_threshold] + end + if semantic_options[:drop_boost_threshold].present? + payload[:drop_boost_threshold] = + semantic_options[:drop_boost_threshold] + end + if semantic_options[:short_query_max_tokens].present? + payload[:short_query_max_tokens] = + semantic_options[:short_query_max_tokens] + end + function_name = ENV.fetch('TIMDEX_SEMANTIC_BUILDER_FUNCTION_NAME') begin From cd4b0b5a0010863c1caa93b2f8316456bae6dce4 Mon Sep 17 00:00:00 2001 From: Jeremy Prevost Date: Wed, 1 Jul 2026 09:40:51 -0400 Subject: [PATCH 2/3] Address automated code review suggestions --- app/graphql/timdex_schema.rb | 4 +- app/graphql/types/query_type.rb | 11 +- app/models/opensearch.rb | 2 +- app/models/semantic_query_builder.rb | 2 +- test/models/semantic_query_builder_test.rb | 182 +++++++++++++++++++++ 5 files changed, 193 insertions(+), 8 deletions(-) diff --git a/app/graphql/timdex_schema.rb b/app/graphql/timdex_schema.rb index 08f3369..1d2f19f 100644 --- a/app/graphql/timdex_schema.rb +++ b/app/graphql/timdex_schema.rb @@ -14,9 +14,9 @@ def self.visible?(member, context) # 2. Extract the root operation fields being requested selected_fields = context.query&.selected_operation&.selections || [] - # 3. Check if any root field starts with "__schema" or "__type" + # 3. Check if any root field is an introspection entrypoint (__schema or __type) is_introspection = selected_fields.any? do |selection| - selection.respond_to?(:name) && selection.name.start_with?('__') + selection.respond_to?(:name) && %w[__schema __type].include?(selection.name) end # 4. Hide the arguments if GraphiQL/Introspection is sniffing the schema diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 2b6f20f..62b32dd 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -106,14 +106,17 @@ def record_id(id:, index:) description: 'Filter by subject terms. Use the `contentType` aggregation ' \ 'for a list of possible values. Multiple values are ANDed.' - # Internal semantic query tuning parameters (not documented publicly). My start with `INTERNAL USE ONLY` to be + # Internal semantic query tuning parameters (not documented publicly). Must start with `INTERNAL USE ONLY` to be # excluded from public documentation. argument :semantic_must_boost_threshold, Float, required: false, default_value: nil, - description: 'INTERNAL USE ONLY: Semantic query must boost threshold (0.0-1.0)' + description: 'INTERNAL USE ONLY: Semantic query must boost ' \ + 'threshold (0.0-1.0)' argument :semantic_drop_boost_threshold, Float, required: false, default_value: nil, - description: 'INTERNAL USE ONLY: Semantic query drop boost threshold (0.0-1.0)' + description: 'INTERNAL USE ONLY: Semantic query drop boost ' \ + 'threshold (0.0-1.0)' argument :semantic_short_query_max_tokens, Integer, required: false, default_value: nil, - description: 'INTERNAL USE ONLY: Semantic query short query max tokens' + description: 'INTERNAL USE ONLY: Semantic query short ' \ + 'query max tokens' end def search(searchterm:, citation:, contributors:, funding_information:, geodistance:, geobox:, identifiers:, diff --git a/app/models/opensearch.rb b/app/models/opensearch.rb index 8bd7c4e..2ac492a 100644 --- a/app/models/opensearch.rb +++ b/app/models/opensearch.rb @@ -72,7 +72,7 @@ def query # Only pass semantic_options to builders that support it (semantic and hybrid) if @query_mode.in?(%w[semantic hybrid]) - builder.build(@params, fulltext: @fulltext, semantic_options: @semantic_options) + builder.build(@params, fulltext: @fulltext, semantic_options: @semantic_options || {}) else builder.build(@params, fulltext: @fulltext) end diff --git a/app/models/semantic_query_builder.rb b/app/models/semantic_query_builder.rb index bb039d6..dd83e34 100644 --- a/app/models/semantic_query_builder.rb +++ b/app/models/semantic_query_builder.rb @@ -15,7 +15,7 @@ def build(params, fulltext: false, semantic_options: {}) end lambda_response = invoke_semantic_builder(query_text, semantic_options) - Rails.logger.debug("SemanticQueryBuilder Lambda response: #{lambda_response.inspect}") + semantic_query = parse_lambda_response(lambda_response) # Validate the query structure has a bool clause before applying filters diff --git a/test/models/semantic_query_builder_test.rb b/test/models/semantic_query_builder_test.rb index 756511f..b8ae9b9 100644 --- a/test/models/semantic_query_builder_test.rb +++ b/test/models/semantic_query_builder_test.rb @@ -198,4 +198,186 @@ def setup_mock_lambda(response_data) assert_includes result[:bool].keys, :filter assert_equal [], result[:bool][:filter] end + + # Tests for semantic_options in Lambda payload + + test 'includes semantic options in lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { must_boost_threshold: 0.5 } + @builder.build(params, semantic_options: semantic_options) + + # Verify the payload contains the semantic option + assert_equal 0.5, captured_payload['must_boost_threshold'] + end + + test 'includes multiple semantic options in lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.5, + drop_boost_threshold: 0.2, + short_query_max_tokens: 10 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify all semantic options are in the payload + assert_equal 0.5, captured_payload['must_boost_threshold'] + assert_equal 0.2, captured_payload['drop_boost_threshold'] + assert_equal 10, captured_payload['short_query_max_tokens'] + end + + test 'omits nil semantic options from lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.5, + drop_boost_threshold: nil, + short_query_max_tokens: nil + } + @builder.build(params, semantic_options: semantic_options) + + # Verify only non-nil options are in the payload + assert_equal 0.5, captured_payload['must_boost_threshold'] + # Verify they are not keys in the payload hash + refute captured_payload.key?('drop_boost_threshold') + refute captured_payload.key?('short_query_max_tokens') + end + + test 'omits empty string semantic options from lambda payload' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: '', + drop_boost_threshold: 0.2 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify empty string options are not included + refute captured_payload.key?('must_boost_threshold') + assert_equal 0.2, captured_payload['drop_boost_threshold'] + end + + test 'includes only query key when no semantic options provided' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + @builder.build(params, semantic_options: {}) + + # Verify only query key is in the payload + assert_equal({ 'query' => query_text }, captured_payload) + end + + test 'semantic options payload contains both query and options' do + query_text = 'test query' + mock_response = { + 'query' => { + 'bool' => { + 'should' => [ + { 'rank_feature' => { 'field' => 'embedding_full_record.test', 'boost' => 5.0 } } + ] + } + } + } + + captured_payload = nil + Aws::Lambda::Client.any_instance.expects(:invoke).with do |args| + captured_payload = JSON.parse(args[:payload]) + true + end.returns(Struct.new(:payload).new(StringIO.new(mock_response.to_json))) + + params = { q: query_text } + semantic_options = { + must_boost_threshold: 0.75, + short_query_max_tokens: 5 + } + @builder.build(params, semantic_options: semantic_options) + + # Verify payload structure contains query and both options + expected_payload = { + 'query' => query_text, + 'must_boost_threshold' => 0.75, + 'short_query_max_tokens' => 5 + } + assert_equal expected_payload, captured_payload + end end From d208728b549b01d20ed021d07c8713531c78f95b Mon Sep 17 00:00:00 2001 From: Jeremy Prevost Date: Wed, 1 Jul 2026 10:00:14 -0400 Subject: [PATCH 3/3] Add tests to confirm fields are hidden --- test/controllers/graphql_controller_test.rb | 92 +++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/test/controllers/graphql_controller_test.rb b/test/controllers/graphql_controller_test.rb index e4c723a..3f8484b 100644 --- a/test/controllers/graphql_controller_test.rb +++ b/test/controllers/graphql_controller_test.rb @@ -1224,4 +1224,96 @@ class GraphqlControllerTest < ActionDispatch::IntegrationTest }' } assert_equal(200, response.status) end + + test 'graphql introspection hides internal semantic arguments via __schema' do + post '/graphql', params: { query: '{ + __schema { + queryType { + fields(includeDeprecated: true) { + name + args { + name + } + } + } + } + }' } + assert_equal(200, response.status) + json = JSON.parse(response.body) + + # Find the 'search' field + search_field = json['data']['__schema']['queryType']['fields'].find { |f| f['name'] == 'search' } + assert(search_field, 'search field should exist in schema') + + # Get all argument names for the search field + arg_names = search_field['args'].map { |arg| arg['name'] } + + # Verify internal semantic arguments are NOT present + assert_not_includes arg_names, 'semanticMustBoostThreshold' + assert_not_includes arg_names, 'semanticDropBoostThreshold' + assert_not_includes arg_names, 'semanticShortQueryMaxTokens' + + # Verify other expected arguments ARE present + assert_includes arg_names, 'searchterm' + assert_includes arg_names, 'sourceFilter' + end + + test 'graphql introspection hides internal semantic arguments via __type' do + post '/graphql', params: { query: '{ + __type(name: "Query") { + fields(includeDeprecated: true) { + name + args { + name + } + } + } + }' } + assert_equal(200, response.status) + json = JSON.parse(response.body) + + # Find the 'search' field + search_field = json['data']['__type']['fields'].find { |f| f['name'] == 'search' } + assert(search_field, 'search field should exist in Query type') + + # Get all argument names for the search field + arg_names = search_field['args'].map { |arg| arg['name'] } + + # Verify internal semantic arguments are NOT present + assert_not_includes arg_names, 'semanticMustBoostThreshold' + assert_not_includes arg_names, 'semanticDropBoostThreshold' + assert_not_includes arg_names, 'semanticShortQueryMaxTokens' + + # Verify other expected arguments ARE present + assert_includes arg_names, 'searchterm' + assert_includes arg_names, 'sourceFilter' + end + + test 'graphql internal semantic arguments still work in actual queries' do + VCR.use_cassette('opensearch init') do + VCR.use_cassette('graphql search data analytics') do + # Verify that the arguments work when sent in a real query + # (they are just hidden from introspection) + post '/graphql', params: { query: '{ + search( + searchterm: "data analytics", + semanticMustBoostThreshold: 0.5, + semanticDropBoostThreshold: 0.2, + semanticShortQueryMaxTokens: 10 + ) { + records { + title + } + } + }' } + assert_equal(200, response.status) + json = JSON.parse(response.body) + + # Verify the query succeeded and returned records + assert_nil json['errors'], "Query should not have errors: #{json['errors']}" + assert_equal('Data analytics and big data', + json['data']['search']['records'].first['title']) + end + end + end end