1. Introduction: The Search Parity Dilemma
Spinning up a vector search prototype takes ten minutes: select an embedding model, upload some documents, and run a query. It feels magical. But in production, that magic quickly hits reality: the Search Parity Dilemma. You struggle to match the precision of your legacy keyword search, because dense embeddings wash away exact-match nuances like product SKUs, error codes, or proprietary acronyms.
To bridge this gap, modern architectures merge semantic dense search with lexical keyword search. If you are migrating a highly tuned search schema to Agent Retrieval (formerly Vector Search 2.0, the retrieval engine of the Gemini Enterprise Agent Platform), this transition can feel like losing a limb—your relevance tuning suddenly becomes a black box. But regaining control comes down to mastering the mechanics of hybrid search. This post breaks down how to conceptually tune Reciprocal Rank Fusion (RRF) and automate weight parameter sweeps to reach optimal search relevance.
TL;DR: Don’t rely on manual linear scoring for hybrid search. This guide walks you through configuring Reciprocal Rank Fusion (RRF) in Vertex AI, correctly implementing the Python SDK’s
BatchSearchDataObjectsRequest, and usingOptunato automatically sweep and discover your optimal search weights.Prerequisites:
- A GCP Project with a Vertex Vector Search 2.0 collection deployed.
- Python SDK:
google-cloud-aiplatform(specifically using thevectorsearch_v1betamodule to access the latest batch search capabilities).optunafor Bayesian optimization.
All the code (the tuning script and configuration) used in this blog post is available in one place: the complete Optuna Tuning Script on GitHub Gist.
2. Reciprocal Rank Fusion (RRF) decoded
To build a high-performing hybrid search engine, you must merge results from two fundamentally different retrieval strategies: dense retrieval (semantic similarity) and sparse retrieval (keyword matching). While dense embeddings understand user intent and concepts (like matching “beachwear” to “swimsuit”), they fail on out-of-domain data like exact SKU numbers, new model revisions, or proprietary code names—cases where traditional keyword search excels.
However, combining raw scores from these two different retrieval strategies introduces significant complexity. You might initially attempt to merge the scores using a weighted linear combination:
This approach is a dangerous architectural anti-pattern. Because dense similarity scores and sparse keyword scores are measured on completely different scales (apples and oranges), trying to normalize and add them is a moving target. The moment you update your document corpus or tweak your search indexing, the range of raw scores shifts. This breaks your hardcoded weights and forces your team into an endless cycle of manual retuning.
RRF analogy and mathematical formula
Instead of linear combinations, we utilize Reciprocal Rank Fusion (RRF). Think of RRF as a democratic voting system. Imagine asking two friends for recommendations: Friend A (Semantic Search) and Friend B (Keyword Search). Normalizing their grades on a scale of 1 to 10 is difficult, so instead, you ask each for their top-5 ordered list. You then combine these lists using their rank position.
Under the hood, Agent Retrieval fuses these lists using the RRF formula:
Where w_i is the configured weight for search stream i, r_i(d) is the 1-based rank position of document d, and k is the smoothing constant fixed to 60.0 (matching the standard RRF default).
Without the constant k, a single outlier ranked 1st on only one list would easily beat a consensus candidate ranked 2nd on both lists. Adding k dampens the rank decay curve, ensuring that items appearing consistently near the top of both lists score higher than outliers in a single stream. Since k is fixed in the platform, your primary levers for relevance tuning are the relative search weights (w_i) and candidate retrieval depths (top_k on sub-searches).
A quick note on RRF Score Normalization: Standard RRF achieves stability by ignoring raw score magnitudes in favor of ordinal ranks. While this prevents a long-text BM25 outlier from dominating the results list, it also means that relative score drops are discarded. In production, you should balance this rank stability by utilizing
VertexRankerto perform secondary semantic rescoring on the top-N candidates.
To understand how search streams and reranking components connect under the hood, look at the following hybrid query processing pipeline:
Hybrid Search Pipeline Flow Diagram
Beyond fusion: VertexRanker for server-side rescoring
RRF produces a robust merged list, but you can improve precision by using a cross-encoder model to semantically re-evaluate the fused candidates. Agent Retrieval supports semantic rescoring natively via VertexRanker. When you add the semantic-ranker-fast@latest model to your search request, the platform rescores the query against target text fields server-side. This eliminates the latency and client-side code complexity of fetching candidates and reranking them in application memory, keeping your Time-To-First-Token (TTFT) low.
3. The relevance tuning playbook
If you are migrating a legacy search engine to Agent Retrieval, your first roadblock is likely relevance tuning. If you are used to writing queries with inline boosts like title^3 OR body^1, you will quickly find that Agent Retrieval does not support direct field-level boosting. Instead, you replicate field boosting by running parallel sub-searches—for example, a text search targeting the category field and a separate search targeting name—and tuning their relative weights in the RRF configuration. Because RRF weights act as relative scaling multipliers, they do not need to sum to 1.0.
To find the optimal weights for your schema, run an automated grid sweep tuning script against a validation dataset. Depending on your use case, this validation dataset will map query inputs to target outputs:
- For Search Engines: Map queries to human-labeled document IDs with graded relevance (like
3for perfect,2for highly relevant,1for marginal, or0for irrelevant), and evaluate retrieval rank quality at each step using Normalized Discounted Cumulative Gain (NDCG@10). - For RAG Applications: Map queries to target ground-truth contexts (or reference document IDs), and optimize the sweep to maximize Contextual Recall or Hit Rate (the percentage of queries where the target source context is successfully retrieved within the top-K results).
The following diagram illustrates this closed-loop optimization sweep:
Closed-Loop Search Relevance Tuning Sweep
How do you automate this tuning process? Run this Python script using the google-cloud-vectorsearch library to sweep candidate weights against your validation dataset. The script utilizes Bayesian Optimization via the Optuna library to sample the parameter space efficiently, avoiding the curse of dimensionality.
import math
import optuna
import google.auth
from google.cloud import vectorsearch_v1beta as vectorsearch
# Configuration parameters
PROJECT_ID = "my-gcp-project"
LOCATION = "us-central1"
COLLECTION_PATH = f"projects/{PROJECT_ID}/locations/{LOCATION}/collections/products-demo"
def calculate_ndcg_at_10(retrieved_docs, ground_truth, k=10):
dcg = 0.0
for idx, doc_id in enumerate(retrieved_docs[:k]):
rel = ground_truth.get(doc_id, 0)
# Apply standard NDCG discounting formula natively
dcg += (2**rel - 1) / math.log2(idx + 2)
sorted_grades = sorted(ground_truth.values(), reverse=True)[:k]
idcg = sum((2**rel - 1) / math.log2(idx + 2) for idx, rel in enumerate(sorted_grades))
return dcg / idcg if idcg > 0 else 0.0
def objective(trial):
# Bayesian Optimization: Optuna suggests candidate weights for each channel in this trial
dense_weight = trial.suggest_float("dense_weight", 0.0, 2.0, step=0.2)
name_text_weight = trial.suggest_float("name_text_weight", 0.0, 2.0, step=0.2)
category_text_weight = trial.suggest_float("category_text_weight", 0.0, 2.0, step=0.2)
# Authenticate securely using standard Application Default Credentials
credentials, _ = google.auth.default()
# Set regional routing endpoint to avoid network round-trip overhead
client_options = {"api_endpoint": f"{LOCATION}-vectorsearch.googleapis.com"}
client = vectorsearch.DataObjectSearchServiceClient(
credentials=credentials,
client_options=client_options
)
# Validation Dataset mapping complex, conflicting intents to target product IDs
# Sanitized validation queries (partner names strictly anonymized)
validation_queries = {
"breathable summer tankini shorts set for women size medium": {
"12777": 3 # Womens MW Tankini Boyshorts Swimsuit
},
"classic vintage blue denim straight leg jeans": {
"4609": 3 # Denim Straight Leg Jean
}
}
ndcg_scores = []
for query_text, ground_truth in validation_queries.items():
# 1. Semantic Search (server-side auto-embedding generation using configured model)
semantic_sub_search = vectorsearch.Search(
semantic_search=vectorsearch.SemanticSearch(
search_text=query_text,
search_field="name_dense_embedding",
task_type="RETRIEVAL_QUERY",
top_k=10,
# Enables instant kNN fallback on sandbox collections without waiting for ScaNN indexes to build
search_hint=vectorsearch.SearchHint(use_knn=True)
)
)
# 2. Text Search targeting name (Field 1)
name_text_sub_search = vectorsearch.Search(
text_search=vectorsearch.TextSearch(
search_text=query_text,
data_field_names=["name"],
top_k=10
)
)
# 3. Text Search targeting category (Field 2)
category_text_sub_search = vectorsearch.Search(
text_search=vectorsearch.TextSearch(
search_text=query_text,
data_field_names=["category"],
top_k=10
)
)
# Construct the unified request with RRF and VertexRanker
request = vectorsearch.BatchSearchDataObjectsRequest(
parent=COLLECTION_PATH,
searches=[semantic_sub_search, name_text_sub_search, category_text_sub_search],
combine=vectorsearch.CombineResultsOptions(
ranker=vectorsearch.Ranker(
rrf=vectorsearch.ReciprocalRankFusion(
weights=[dense_weight, name_text_weight, category_text_weight]
),
vertex_ranker=vectorsearch.VertexRanker(
model="semantic-ranker-fast@latest",
top_n=10,
text_record_spec=vectorsearch.VertexRanker.TextRecordSpec(
query=query_text,
content_template="{category} {name}"
)
)
),
output_fields=vectorsearch.OutputFields(
data_fields=["name", "category"]
),
top_k=10
)
)
# Execute the request natively against the regional GCP API endpoint
try:
response = client.batch_search_data_objects(request=request)
retrieved_docs = [match.data_object.data_object_id for match in response.results[0].results] if response.results else []
except Exception:
retrieved_docs = []
score = calculate_ndcg_at_10(retrieved_docs, ground_truth, k=10)
ndcg_scores.append(score)
return sum(ndcg_scores) / len(ndcg_scores) if ndcg_scores else 0.0
if __name__ == "__main__":
print(f"Starting Bayesian Optimization over {COLLECTION_PATH}...")
study = optuna.create_study(direction="maximize")
# Sweep through 50 probabilistic trials
study.optimize(objective, n_trials=50)
print("\n" + "="*50)
print("Optimization Sweep Complete!")
print(f"Max Mean NDCG@10: {study.best_value:.4f}")
print(f"Optimal Weights : [Dense: {study.best_params['dense_weight']:.1f}, Name Text: {study.best_params['name_text_weight']:.1f}, Category Text: {study.best_params['category_text_weight']:.1f}]")
print("="*50)
In practice, your optimal weights will rarely settle on a clean [1.0, 1.0] default:
- SKUs, Model Numbers, and Acronyms: Often optimize at a higher sparse weight (e.g.,
[1.0, 1.6]) to preserve lexical precision. - Conversational or Long-Form Queries: Often optimize at a lower sparse weight (e.g.,
[1.0, 0.6]) to prioritize semantic context matching.
Scaling to enterprise-grade validation sets
A sandbox validation set with two queries is a useful tool to verify your SDK connection, but it is not a representative testing pipeline. In a production enterprise system, validation requires extracting at least 1,000+ distinct queries from actual search logs.
Additionally, instead of manual grades, build a structured matrix mapping query strings to document IDs. You can generate these grades through implicit feedback signals like Click-Through Rate (CTR), Add-to-Cart events, and conversion logs.
4. Conclusion: Elevating retrieval to an engineering discipline
Achieving production-grade search relevance is not a one-time configuration change. It is an ongoing, data-driven engineering discipline. By replacing fragile linear scoring combinations with server-side RRF and automating parameter sweeps, you convert operational search challenges into shared wisdom. As you transition your retrieval pipelines from prototype to production, establishing these systematic baselines will ensure your generative AI applications remain accurate, robust, and cost-effective.
(Want to run the automated tuning loop in your own environment? Grab the full, templated Optuna Python script from this GitHub Gist to get started immediately.)
How are you evaluating your Vector Search instances? Are you relying on NDCG@10, optimizing for Contextual Recall in RAG, or using a custom metric? Let me know in the comments below!

