Labwrights

Guides · 4 min read · October 26, 2026

Hybrid search in Azure AI Search: keyword and vector in one query

People search in two ways. Sometimes they type an exact name: a tool, a service, an error code. Sometimes they describe a problem in their own words. Keyword search is good at the first and vector search is good at the second. Hybrid search runs both for the same query and merges the results. In Azure AI Search, it's one query.

The results below are real captures from a RAG system built on a corpus of 100 workplace documents, run on live Azure in October 2026.

Two kinds of search

Keyword search finds documents that contain your words. Azure AI Search ranks them with BM25, which rewards rare words that appear often in a document. It's precise on names, codes, and jargon. It's blind to meaning: "checkout broke" doesn't match a postmortem that says "checkout outage".

Vector search compares meaning. An embedding model turns each passage, and each query, into a vector, a list of numbers. Passages whose vectors sit close to the query's vector come first. It finds paraphrases well. It's weaker on exact identifiers, because a tool name like deployctl carries little meaning for the model.

In a RAG system, users send both kinds of query. So the retrieval step needs both.

How the merge works: Reciprocal Rank Fusion

You can't just add the two scores. BM25 scores and vector similarities sit on different scales, like one judge scoring out of 100 and another out of 1.

Reciprocal Rank Fusion (RRF) ignores the scores and uses only positions. Each list gives every result 1 / (60 + rank) points, and the points are added up. A result near the top of either list rises. A result near the top of both rises the most. It needs no tuning, and it's hard for one list to dominate.

That's also why hybrid scores look odd. They come back around 0.03, because they're sums of small fractions. Read them as an ordering, never as a percentage, and never compare them with scores from another search mode.

The index

The index needs a text field for the keyword side and a vector field for the vector side. In Python, with the azure-search-documents library:

fields = [
    SimpleField(name="id", type=SearchFieldDataType.String, key=True),
    SearchableField(name="text", type=SearchFieldDataType.String),   # keyword side
    SearchableField(name="title", type=SearchFieldDataType.String),
    SimpleField(name="file", type=SearchFieldDataType.String, filterable=True),
    SearchField(                                                     # vector side
        name="vector",
        type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
        searchable=True,
        vector_search_dimensions=1536,  # must match the embedding model
        vector_search_profile_name="vprofile",
    ),
]
vector_search = VectorSearch(
    algorithms=[HnswAlgorithmConfiguration(name="hnsw")],
    profiles=[VectorSearchProfile(name="vprofile", algorithm_configuration_name="hnsw")],
)

1536 matches text-embedding-3-small. Use a different embedding model, and this number changes with it. HNSW is the approximate nearest-neighbor index that keeps vector search fast at scale.

The query

Passing both search_text and vector_queries is the whole hybrid feature. The service runs both rankings and merges them with RRF before it answers:

from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizedQuery

client = SearchClient(endpoint, index_name, credential)
results = client.search(
    search_text=query,                                    # keyword side
    vector_queries=[
        VectorizedQuery(vector=embed(query), k_nearest_neighbors=50, fields="vector")
    ],                                                    # vector side
    select=["text", "title", "file"],
    top=5,
)

embed(query) is your call to the same embedding model you used for the passages. k_nearest_neighbors=50 gives the vector side a deep list to merge, even though you only keep the top 5.

Real results

An exact name, where keyword search is strong:

query: deployctl rollback checkout-api
0.0333  runbook-checkout-api.md  —  Runbook: checkout-api
0.0318  runbook-catalog-service.md  —  Runbook: catalog-service
0.0317  runbook-auth-service.md  —  Runbook: auth-service

The tool name and service name put the right runbook on top.

A paraphrase, where vector search is strong:

query: checkout broke
0.0333  runbook-checkout-api.md  —  Runbook: checkout-api
0.0320  postmortem-checkout-latency.md  —  Postmortem: checkout latency (2026-07-14)
0.0268  service-catalog.csv  —  Table service-catalog.csv — columns: service, team, tie

On the same corpus, vector search alone put three runbooks on top and missed the checkout postmortem entirely. Hybrid puts it second. The merge isn't just a tie-breaker. It finds results that either side alone loses.

A scanned paper memo, read earlier with OCR:

query: when do old badges stop working
0.0333  scanned-facilities-memo.pdf  —  FACILITIES MEMO X badge reader replacement
0.0308  policy-ai-usage.html  —  AI Tool Usage
0.0303  postmortem-stale-catalog.md  —  Postmortem: stale catalog (2026-01-25)

When hybrid isn't best

Hybrid is the safe default, not a guarantee. On a test set made almost entirely of paraphrased questions, pure vector search beat hybrid. The answer isn't a rule of thumb. It's a golden set: real questions, each with the document that should answer it, scored after every change.

When the right document shows up in the top results but too low, add a re-ranker. Azure AI Search's semantic ranker reads the query with each top result and re-orders them. It's often the biggest single quality gain in a RAG system. It adds a few hundred milliseconds per query, and it needs a paid tier.

What it costs

Azure AI Search bills by the hour, by tier. The free tier is small: one per subscription, about 50 MB, and three indexes. The Basic tier was about $0.11 an hour in October 2026, whether you query it or not. For learning, create it when you need it and delete it at the end of the session. An index is disposable, as long as your chunks and vectors are stored somewhere else, such as your database.