Conversation
Why these changes are being introduced: Now that we have separate builders for semantic and lexical queries, we can add an option to combine the two. Relevant ticket(s): - [USE-413](https://mitlibraries.atlassian.net/browse/USE-413) - [USE-414](https://mitlibraries.atlassian.net/browse/USE-414) How this addresses that need: This adds a new query builder model for hybrid queries in OpenSearch, and exposes the new query type in the GraphQL API. Side effects of this change: - We may need to manually weight the scoring due to the different scoring algorithms used by semantic and lexical search. That felt out of scope of this change, but may warrant further exploration. - The query builder checks only for errors in the semantic builder. We don't have any error handling in the lexical builder because it doesn't involve any external calls. This is probably okay because it's highly unlikely for lexical search to error, but worth noting.
❌ 6 blocking issues (8 total)
|
There was a problem hiding this comment.
Pull request overview
Adds support for a new “hybrid” OpenSearch query mode that combines existing semantic (vector) and lexical (keyword) query builders, and exposes the mode via the GraphQL API.
Changes:
- Add
HybridQueryBuilderto combine semantic + lexical queries with a fallback to lexical-only on semantic failure. - Route
query_mode: 'hybrid'to the new builder inOpensearch#query. - Update GraphQL
query_modeargument description and add tests for the new builder selection and hybrid builder behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
app/models/opensearch.rb |
Routes query_mode == 'hybrid' to HybridQueryBuilder. |
app/models/hybrid_query_builder.rb |
Implements hybrid query composition and semantic-failure fallback behavior. |
app/graphql/types/query_type.rb |
Documents the new query_mode option in the GraphQL API. |
test/models/opensearch_test.rb |
Adds a unit test asserting HybridQueryBuilder is selected for query_mode: hybrid. |
test/models/hybrid_query_builder_test.rb |
Adds unit tests for hybrid builder blank-query behavior, composition, key normalization, and fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
bcd1cf0 to
d4356bf
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This implements some feedback from Copilot during code review.
810aed4 to
7468316
Compare
JPrevost
left a comment
There was a problem hiding this comment.
I don't really understand some of the implementation decisions and added comments where I could use some more details.
I'd also like confirmation that you manually confirmed expected behavior before generating mocks. Mocks are great if they are used with caution, but with zero tests included that are not mocks I'd like to better understand how much you worked with actual responses in order to create the correct mocks.
I think we are missing GraphQL tests for both Hybrid and Semantic. Maybe if we add those and use VCR instead of mocks I'd feel more comfortable with the mocks here.
|
|
||
| # Logs semantic query builder failures for observability | ||
| def log_semantic_error(error) | ||
| return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger |
There was a problem hiding this comment.
This line feels overly cautious? In what situation do we think Rails won't exist and/or the logger won't be configured? We use it all the time in this application without a block like this right?
There was a problem hiding this comment.
Ha! Yeah, if Rails doesn't exist, we have bigger problems. Good catch.
| end | ||
|
|
||
| # Only fall back to lexical for Lambda/invocation errors from SemanticQueryBuilder. | ||
| # All other errors (parsing, validation, unexpected bugs) should be re-raised. |
There was a problem hiding this comment.
I feel like all StandardErrors are raised as a LambdaError from SemanticBuilder. Does this do what the comment says?
There was a problem hiding this comment.
I think so, yeah. Only the invoke call is wrapped in LambdaError. parse_lambda_payload and parse_lambda_response calls happen outside the rescue block, so JSON parsing and response validation errors should propagate as runtime errors.
| end | ||
|
|
||
| # Recursively converts all string keys to symbols in hashes and nested structures. | ||
| # This ensures consistency since semantic builder returns string keys while lexical uses symbols. |
There was a problem hiding this comment.
Is this indicative of an inconsistency we need to fix in one of the Builders rather than normalizing it here? Why are Lexical and Semantic different?
There was a problem hiding this comment.
Yeah, I think that's a reasonable conclusion. The issue is that SemanticQueryBuilder receives JSON from the Lambda (which has string keys) and returns it as-is, while LexicalQueryBuilder builds hashes with symbol keys natively. It might be better to have SemanticQueryBuilder return symbol keys. I can make that change.
There was a problem hiding this comment.
If you'd prefer to treat the SemanticBuilder change as maintenance we can add a ticket or add it to the Confluence page that notes tech debt for this repo. Your call if we do this later or now.
| payload: payload.to_json | ||
| ) | ||
| rescue StandardError => e | ||
| # Only Lambda invocation errors are wrapped in LambdaError for graceful fallback |
There was a problem hiding this comment.
I'm curious about this comment and the logic in the Hybrid builder that relies on this. Are we not just raising all standard errors as LambdaErrors here? What does "Only Lambda invocation errors are wrapped in LambdaError for graceful fallback" mean here?
There was a problem hiding this comment.
Yeah, we are. I can see how the comment might suggest we're being more selective. What I meant is that all errors from the invoke call get wrapped (allowing graceful fallback), while errors from parsing the response (below the rescue block) don't get wrapped and fail fast. I'll clarify the comment.
|
|
||
| result = @builder.build(params) | ||
|
|
||
| # When q is blank, should return lexical query (preserves filters) |
There was a problem hiding this comment.
Does the API actually support this scenario? I think probably?
There was a problem hiding this comment.
It does, yeah. Whether it should is another question. :)
|
|
||
| result = @builder.build(params) | ||
|
|
||
| # Without filters, minimum_should_match should not be set |
There was a problem hiding this comment.
Can you help me understand why a minimum should match should be different when no filters are in place? I understand why it was in place for the filters, but does it change any results if we kept it for all queries? I'm not asking for change, just help understanding as to what this test
There was a problem hiding this comment.
It doesn't change anything; it's just redundant to include minimum_should_match in non-filter searches. We don't necessarily have to test that behavior if you think it's confusing. (Or, we could change the behavior to always set minimum_should_match.)
There was a problem hiding this comment.
This makes sense, thanks for helping me understand it.
| lexical_mock.stubs(:build).returns({ bool: { should: [], must: [], filter: [] } }) | ||
| semantic_mock = mock | ||
| # Non-Lambda errors (parsing, validation) should be re-raised | ||
| semantic_mock.stubs(:build).raises(StandardError.new('Invalid semantic query builder response: missing query key')) |
There was a problem hiding this comment.
This is the situation I don't understand if we can actually get to in code even if we can mock it. I may be misinterpreting how it works, but it looks like we just re-raise all standard errors as lambda errors and therefore we can't ever actually get to this outside of mocks.
There was a problem hiding this comment.
Yeah, I think you're right. I'll drop this one.
|
|
||
| # Both succeeded - combine them with should clause while preserving filters | ||
| combine_queries(semantic_query, lexical_query) | ||
| rescue StandardError => e |
There was a problem hiding this comment.
This feels slightly odd to rescue Standard and then filter what we actually want to raise rather than rescuing the actual things we want to handle. Is there a reason you went with StandardError here rather than specific error types?
There was a problem hiding this comment.
Yeah, it does feel overly defensive. We can just rescue SemanticQueryBuilder::LambdaError here catching StandardError and filtering.
|
|
||
| { | ||
| bool: hybrid_bool | ||
| } |
There was a problem hiding this comment.
Found 5 issues:
1. Function with high complexity (count = 9): combine_queries [qlty:function-complexity]
2. Assignment Branch Condition size for combine_queries is too high. [<7, 14, 11> 19.13/17] [rubocop:Metrics/AbcSize]
3. Cyclomatic complexity for combine_queries is too high. [11/7] [rubocop:Metrics/CyclomaticComplexity]
4. Method has too many lines. [23/10] [rubocop:Metrics/MethodLength]
5. Perceived complexity for combine_queries is too high. [12/8] [rubocop:Metrics/PerceivedComplexity]
|
|
||
| # Response parsing below is outside the rescue block, so JSON/validation errors | ||
| # propagate as-is and fail fast rather than triggering graceful fallback. | ||
| parse_lambda_payload(response.payload) |
| value.map { |item| normalize_keys(item) } | ||
| else | ||
| value | ||
| end |
a3bf55a to
ab32c4e
Compare
|
@JPrevost Thanks for the thorough review! Could you let me know if the latest commit addresses your concerns? I added a few VCR tests, but I still stubbed the Lambda responses because I was getting auth errors. I assume it's set up to accept requests only from specific domains, but happy to explore further if that assumption is incorrect. |
JPrevost
left a comment
There was a problem hiding this comment.
This looks good. Only remaining minor nit is calling a logging method instead of just doing the Rails.logger call directly.
I'm also nervous we're just logging and not throwing to Sentry when we silently fallback like this. That should be considered exceptional so maybe it's worth adding that here too as we'll never notice this in the logs?
No change required, but the lambdas are not restricted from being called from specific URLs so your local .env was likely just not updated to support them. You need the same lambda tier as your open search tier to ensure they work as expected.
| # Semantic builder failed (Lambda error, etc.) - use lexical only | ||
| rescue SemanticQueryBuilder::LambdaError => e | ||
| # Lambda service failure - gracefully fall back to lexical search | ||
| log_semantic_error(e) |
There was a problem hiding this comment.
I'd bring that logging statement into this block. It currently only does a logging statement so this is mostly just indirection.
| "HybridQueryBuilder semantic query failed: #{e.class}: #{e.message}" | ||
| ) | ||
| lexical_query | ||
| end |
|
@JPrevost Could you please check the latest commit to wrap the Lambda calls with VCR? It's not a huge change, but I'd like a second set of eyes before we merge. |
| secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY') | ||
| ) | ||
| end | ||
| options = { |
There was a problem hiding this comment.
Yeah this is probably better configuration even if you weren't adding the new feature to override the endpoint :)
| EMAIL_URL_HOST=localhost:3000 | ||
| JWT_SECRET_KEY=3862fc949629030de4259b88f6e8f7c3702b2fabfc68d00d46fb7f9f70110690b526997ef4d77765ffa010d8aba440286af39947d0c85287174d99be2db14987 | ||
| OPENSEARCH_INDEX=all-current | ||
| AWS_ENDPOINT_URL_LAMBDA=http://localhost:9200 |
There was a problem hiding this comment.
I think I'm okay with this not being documented as we don't anticipate it ever being used anywhere but the tests.
There was a problem hiding this comment.
Oops, I did mean to document it, just to say that it's test env only. Will do so before merging.
Why these changes are being introduced:
Now that we have separate builders for semantic
and lexical queries, we can add an option to
combine the two.
Relevant ticket(s):
How this addresses that need:
This adds a new query builder model for hybrid
queries in OpenSearch, and exposes the new query
type in the GraphQL API.
Side effects of this change:
semantic and lexical search. That felt out of
scope of this change, but may warrant further
exploration.
handling in the lexical builder because it
doesn't involve any external calls. This is
probably okay because it's highly unlikely for
lexical search to error, but worth noting.
Developer
our guide and
all issues introduced by these changes have been resolved or opened as new
issues (link to those issues in the Pull Request details above)
Code Reviewer
(not just this pull request message)
Requires database migrations?
NO
Includes new or updated dependencies?
NO