How to Implement Hybrid Search Combining Lexical and Vector Queries in OpenSearch
The sample application implements hybrid search by merging Bedrock-extracted lexical filters with Cohere-generated dense vectors in a single OpenSearch hybrid query that combines bool-should clauses for term matching and k-NN for vector similarity.
This tutorial repository demonstrates a production-ready pattern for hybrid search on Amazon OpenSearch Service, showing how to blend traditional keyword filtering with AI-powered semantic retrieval. The implementation lives in aws-samples/sample-for-amazon-opensearch-service-tutorials-101 and uses AWS Lambda, Amazon Bedrock, and OpenSearch Service to process natural language queries into structured lexical constraints while simultaneously performing dense vector similarity search.
How the Hybrid Search Workflow Works
The application processes user queries through a four-stage pipeline that bridges lexical and vector search paradigms. When the Lambda handler receives a request with "type": "hybrid_search", it executes the following sequence:
Step 1: Extract Semantic Filters with Amazon Bedrock
The function identify_category_color_product_name() invokes the nova-lite-v1 foundation model via Amazon Bedrock to parse free-text queries into structured attributes. The LLM extracts category, color, and product_type from inputs like "red women running shoes" and returns strict JSON conforming to predefined taxonomies.
Step 2: Generate Dense Vector Embeddings
Simultaneously, get_embedding() calls the Cohere embed-english-v3 model through Bedrock to transform the raw search text into a 768-dimensional dense vector. This embedding captures semantic meaning beyond literal keyword matches.
Step 3: Construct the Hybrid OpenSearch Query
In artifacts/search_lambda/opensearch_search.py (lines 401-462), the Lambda assembles a hybrid query structure that contains:
- A bool-should clause with
termqueries for each extracted attribute (category,color,product_type) - A k-NN clause targeting the
vector_embeddingfield with the 768-dimensional Cohere vector - A post_filter ensuring final results satisfy all lexical constraints regardless of vector similarity scores
The query optionally routes through a custom search pipeline defined by SEARCH_PIPELINE_NAME for additional preprocessing.
Core Implementation Details
The hybrid search logic resides in the search_products() function within opensearch_search.py. The implementation uses OpenSearch's native hybrid query syntax to execute both retrieval methods in a single API call:
- Lexical component: The
should_match_conditionslist contains individualtermqueries for detected attributes, withminimum_should_match: 1ensuring at least one lexical filter matches - Vector component: The
knnquery searches thevector_embeddingfield withk: 100neighbors using the Cohere-generated vector - Post-filtering: A separate
post_filterbool query withmustconstraints guarantees that vector search results are filtered by the extracted lexical attributes, preventing semantic matches that violate categorical constraints
The system supports both on_disk and in_memory index modes via the mode parameter, allowing flexibility between cost efficiency and query latency.
Code Examples
Sample API Request Payload
The Lambda expects this JSON structure to trigger hybrid search processing:
{
"type": "hybrid_search",
"attribute_value": "red women running shoes",
"mode": "on_disk"
}
The type field distinguishes hybrid search from pure lexical or vector modes, while mode selects the OpenSearch index storage type.
Python Client Implementation
Call the search endpoint via API Gateway using standard HTTP requests:
import requests
import json
url = "https://<api-id>.execute-api.<region>.amazonaws.com/prod/search"
payload = {
"type": "hybrid_search",
"attribute_value": "red women running shoes",
"mode": "on_disk"
}
response = requests.post(url, json=payload)
results = response.json()
print(json.dumps(results, indent=2))
The Hybrid Query Structure
The Lambda constructs this OpenSearch query body in opensearch_search.py:
search_body = {
"size": 100,
"_source": {"excludes": "vector_embedding"},
"query": {
"hybrid": {
"queries": [
{
"bool": {
"should": should_match_conditions,
"minimum_should_match": 1
}
},
{
"knn": {
"vector_embedding": {
"vector": vector_embedding,
"k": 100
}
}
}
]
}
},
"post_filter": {
"bool": {
"must": should_match_conditions
}
},
"search_pipeline": SEARCH_PIPELINE_NAME
}
The should_match_conditions list contains term filters extracted by the LLM, while vector_embedding holds the Cohere-generated dense vector.
LLM-Driven Filter Extraction Function
The semantic parsing uses carefully engineered prompts to constrain the LLM output:
def identify_category_color_product_name(search_text):
prompt = f"""Given the search text: "{search_text}",
identify the most likely product category, color and product_name.
Choose only from these categories: men, women, unisex.
Choose only from these colors: red, blue, green, yellow, multicolor, orange,
purple, pink, brown, black, white, grey, white,coral, gold, teal,
burgundy, silver.
Choose only from these product types: shoes, bag, apparel, accessories,
innerwear, other
Return strictly a json with category,color, product_type"""
# Bedrock invocation returns: {"category":"women","color":"red","product_type":"shoes"}
return bedrock_response
This function returns standardized JSON that maps directly to OpenSearch term query filters.
Key Files and Architecture
Understanding the repository structure helps navigate the implementation:
artifacts/search_lambda/opensearch_search.py: Core Lambda handler containingsearch_products(),get_embedding(), andidentify_category_color_product_name(); implements the hybrid query construction at lines 401-462app.py: Flask wrapper for local testing that simulates the Lambda execution environmentsearch_tutorials/: CDK stacks provisioning the OpenSearch domain, Lambda layers, Bedrock permissions, and API Gateway endpointsREADME.md: Deployment instructions and environment variable configuration forSEARCH_PIPELINE_NAMEand model ARNs
Summary
- The sample application combines lexical filtering (via Bedrock LLM extraction) with dense vector search (via Cohere embeddings) in a single OpenSearch hybrid query
- The
hybridquery syntax mergesbool-shouldterm filters withknnvector similarity, whilepost_filterensures lexical constraints are strictly enforced - Implementation resides in
opensearch_search.pywithidentify_category_color_product_name()handling semantic extraction andget_embedding()managing vector generation - The architecture supports both on-disk and in-memory index configurations through the
modeparameter - Search pipelines allow preprocessing via
SEARCH_PIPELINE_NAMEbefore hybrid query execution
Frequently Asked Questions
What is the difference between the lexical and vector components in this hybrid search?
The lexical component uses traditional term matching on structured attributes (category, color, product_type) extracted by the nova-lite-v1 model, while the vector component performs semantic similarity search using 768-dimensional Cohere embeddings. The lexical filters constrain the result set to relevant categories, while the vector component ranks results by semantic meaning within those constraints.
Why does the implementation use a post_filter in addition to the hybrid query?
The post_filter ensures that results returned by the k-NN vector search are subsequently filtered by the lexical constraints using a bool.must clause. This guarantees that high-scoring vector matches which violate the extracted categorical filters (e.g., returning men's shoes for a women's query) are removed from the final result set, maintaining both semantic relevance and categorical accuracy.
Which foundation models does the sample application use for hybrid search?
The application uses Amazon Nova Lite v1 (accessed via Amazon Bedrock) for extracting structured filters from natural language queries, and Cohere embed-english-v3 (also via Bedrock) for generating the 768-dimensional dense vectors used in the k-NN search component. These models are invoked in the identify_category_color_product_name() and get_embedding() functions respectively.
How does the search_pipeline parameter enhance the hybrid search?
The search_pipeline field (configured via the SEARCH_PIPELINE_NAME environment variable) allows OpenSearch to apply additional processing steps—such as synonym expansion, query normalization, or reranking—before executing the hybrid query. This enables the application to modify or enrich both the lexical and vector query components through OpenSearch's native pipeline processors without changing the Lambda code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →