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 term queries for each extracted attribute (category, color, product_type)
  • A k-NN clause targeting the vector_embedding field 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_conditions list contains individual term queries for detected attributes, with minimum_should_match: 1 ensuring at least one lexical filter matches
  • Vector component: The knn query searches the vector_embedding field with k: 100 neighbors using the Cohere-generated vector
  • Post-filtering: A separate post_filter bool query with must constraints 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 containing search_products(), get_embedding(), and identify_category_color_product_name(); implements the hybrid query construction at lines 401-462
  • app.py: Flask wrapper for local testing that simulates the Lambda execution environment
  • search_tutorials/: CDK stacks provisioning the OpenSearch domain, Lambda layers, Bedrock permissions, and API Gateway endpoints
  • README.md: Deployment instructions and environment variable configuration for SEARCH_PIPELINE_NAME and 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 hybrid query syntax merges bool-should term filters with knn vector similarity, while post_filter ensures lexical constraints are strictly enforced
  • Implementation resides in opensearch_search.py with identify_category_color_product_name() handling semantic extraction and get_embedding() managing vector generation
  • The architecture supports both on-disk and in-memory index configurations through the mode parameter
  • Search pipelines allow preprocessing via SEARCH_PIPELINE_NAME before hybrid query execution

Frequently Asked Questions

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.

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.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →