| name | multi-query |
| description | Use when search queries need better recall through query expansion - generates multiple query variants, retrieves with each, and fuses results using RRF for improved retrieval quality especially with ambiguous or under-specified queries |
| version | 0.5.0 |
LLMemory Multi-Query Expansion
Installation
uv add llmemory
pip install llmemory
Overview
Multi-query expansion improves search recall by:
- Generating multiple query variants from the original query
- Searching with each variant independently
- Fusing results using Reciprocal Rank Fusion (RRF)
- Returning unified, deduplicated results
Two expansion modes:
- Heuristic (default): Fast lexical variants using keyword extraction, OR clauses, and phrase matching. No LLM calls, <1ms latency.
- LLM-based (configurable): Semantic query variants using GPT-4o-mini or similar. Better recall, 50-200ms latency, requires API key.
When to use multi-query expansion:
- Queries are ambiguous or under-specified
- Want to capture different perspectives or phrasings
- Improve recall for complex information needs
- User queries tend to be short or vague
When NOT to use:
- Queries are already very specific
- Latency is critical (multi-query adds overhead)
- Simple keyword lookups
Quick Start
from llmemory import LLMemory, SearchType
async with LLMemory(connection_string="postgresql://localhost/mydb") as memory:
results = await memory.search(
owner_id="workspace-1",
query_text="improve customer satisfaction",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
limit=10
)
for result in results:
print(f"[{result.rrf_score:.3f}] {result.content[:80]}...")
Complete API Documentation
search() with Query Expansion
Signature:
async def search(
owner_id: str,
query_text: str,
search_type: Union[SearchType, str] = SearchType.HYBRID,
limit: int = 10,
query_expansion: Optional[bool] = None,
max_query_variants: Optional[int] = None,
**kwargs
) -> List[SearchResult]
Query Expansion Parameters:
-
query_expansion (bool, optional): Enable/disable query expansion
None (default): Follow global config (LLMEMORY_ENABLE_QUERY_EXPANSION)
True: Force enable for this search
False: Force disable for this search
-
max_query_variants (int, optional): Maximum query variants to generate
- Default: 3 (from config
search.max_query_variants)
- Range: 1-5 (practical limit for performance)
- Includes original query + (N-1) generated variants
Returns:
List[SearchResult] with multi-query specific behavior:
- Results fused from all query variants using RRF
- Each result's
rrf_score reflects consensus across variants
- Deduplicatedby chunk_id (same chunk from multiple variants counted once)
Example:
results = await memory.search(
owner_id="workspace-1",
query_text="reduce server latency",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
limit=15
)
How Multi-Query Works
Query Variant Generation
llmemory generates query variants using heuristic rules (no LLM required by default):
Original Query: "customer retention strategies"
Generated Variants:
1. "customer retention strategies" (original, always included)
2. "customer OR retention OR strategies" (OR variant - widens lexical recall)
3. "\"customer retention strategies\"" (quoted phrase - exact match)
Each variant captures a different matching strategy:
- Original: Standard BM25 matching
- OR variant: Boolean OR to catch documents with any key term
- Quoted phrase: Exact phrase matching for precision
With stopwords:
Original Query: "how to improve the customer satisfaction"
Generated Variants:
1. "how to improve the customer satisfaction" (original)
2. "how improve customer satisfaction" (keyword variant - stopwords removed)
3. "how OR improve OR customer OR satisfaction" (OR variant)
4. "\"how to improve the customer satisfaction\"" (quoted phrase)
Search Execution
variants = [
"customer retention strategies",
"customer OR retention OR strategies",
"\"customer retention strategies\""
]
results_1 = await search(query=variants[0], ...)
results_2 = await search(query=variants[1], ...)
results_3 = await search(query=variants[2], ...)
final_results = rrf_fusion([results_1, results_2, results_3])
RRF Fusion
Reciprocal Rank Fusion combines results from multiple query variants:
For each query variant:
For each result in that variant:
score = 1 / (k + rank + 1)
For each unique chunk (by chunk_id):
total_score = sum of scores from all variants
Sort by total_score descending
Where k = 50 (constant that prevents top-ranked results from dominating). Note the + 1 ensures the first result (rank=0) gets score 1/(50+0+1) = 1/51 rather than 1/50.
Key insight: Chunks appearing in multiple variant result sets get higher RRF scores, indicating strong relevance consensus.
Practical Examples
Customer Support Search
results = await memory.search(
owner_id="support-team",
query_text="login problems",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
limit=20
)
Product Documentation Search
results = await memory.search(
owner_id="docs-site",
query_text="async function error handling",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=4,
limit=15
)
Research & Discovery
results = await memory.search(
owner_id="research-db",
query_text="climate change mitigation",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=5,
limit=25
)
E-commerce Search
results = await memory.search(
owner_id="store-1",
query_text="lightweight laptop for travel",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
metadata_filter={"category": "electronics"},
limit=20
)
Configuration
Global Configuration
LLMEMORY_ENABLE_QUERY_EXPANSION=1
LLMEMORY_MAX_QUERY_VARIANTS=3
Programmatic Configuration
from llmemory import LLMemoryConfig
config = LLMemoryConfig()
config.search.enable_query_expansion = True
config.search.max_query_variants = 3
memory = LLMemory(
connection_string="postgresql://localhost/mydb",
config=config
)
LLM-Based Expansion (Advanced)
For semantic query diversity, enable LLM-based expansion:
Environment Variables:
LLMEMORY_ENABLE_QUERY_EXPANSION=1
LLMEMORY_QUERY_EXPANSION_MODEL=gpt-4o-mini
OPENAI_API_KEY=sk-...
Programmatic Configuration:
from llmemory import LLMemoryConfig
config = LLMemoryConfig()
config.search.enable_query_expansion = True
config.search.query_expansion_model = "gpt-4o-mini"
config.search.max_query_variants = 3
memory = LLMemory(
connection_string="postgresql://localhost/mydb",
openai_api_key="sk-...",
config=config
)
LLM vs Heuristic Comparison:
| Mode | Latency | Quality | Cost | Use Case |
|---|
| Heuristic | <1ms | Good | Free | Default, high-QPS |
| LLM | 50-200ms | Excellent | ~$0.001/query | Quality-critical |
LLM Expansion Example:
results = await memory.search(
owner_id="workspace-1",
query_text="improve customer retention",
query_expansion=True,
max_query_variants=3,
limit=10
)
Per-Query Override
results = await memory.search(
owner_id="workspace-1",
query_text="vague query here",
query_expansion=True,
max_query_variants=4,
limit=10
)
results = await memory.search(
owner_id="workspace-1",
query_text="very specific query",
query_expansion=False,
limit=10
)
Performance Considerations
Latency Impact
import time
start = time.time()
results = await memory.search(
owner_id="workspace-1",
query_text="test query",
query_expansion=False,
limit=10
)
elapsed_single = (time.time() - start) * 1000
print(f"Single query: {elapsed_single:.2f}ms")
start = time.time()
results = await memory.search(
owner_id="workspace-1",
query_text="test query",
query_expansion=True,
max_query_variants=3,
limit=10
)
elapsed_multi = (time.time() - start) * 1000
print(f"Multi-query: {elapsed_multi:.2f}ms")
Optimizing Performance
results = await memory.search(
owner_id="workspace-1",
query_text="query",
query_expansion=True,
max_query_variants=2,
limit=10
)
When to Use Multi-Query
Good use cases:
- Queries that might match with different keyword combinations
- Short queries where OR expansion helps recall
- Queries where both exact and fuzzy matching are valuable
- Cases where you want to balance precision (quoted) and recall (OR)
Avoid for:
- Very specific queries (expansion doesn't help)
- High-QPS API endpoints (multiplies search cost)
- When latency is critical (3x+ base search time)
- Pure semantic search (heuristic variants don't add semantic diversity)
Combining with Other Features
Multi-Query + Reranking
results = await memory.search(
owner_id="workspace-1",
query_text="machine learning deployment",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
rerank=True,
rerank_top_k=50,
rerank_return_k=15,
limit=15
)
Multi-Query + Metadata Filtering
results = await memory.search(
owner_id="workspace-1",
query_text="quarterly performance",
search_type=SearchType.HYBRID,
query_expansion=True,
max_query_variants=3,
metadata_filter={
"department": "finance",
"year": 2024
},
date_from=datetime(2024, 1, 1),
limit=20
)
Multi-Query + Hybrid Search Tuning
results = await memory.search(
owner_id="workspace-1",
query_text="customer feedback analysis",
search_type=SearchType.HYBRID,
alpha=0.6,
query_expansion=True,
max_query_variants=3,
limit=15
)
Monitoring and Debugging
Inspecting Query Variants
Multi-query search logs include the generated variants in diagnostics:
Understanding RRF Scores
results = await memory.search(
owner_id="workspace-1",
query_text="test",
query_expansion=True,
max_query_variants=3,
limit=10
)
for result in results:
print(f"Chunk: {result.chunk_id}")
print(f" RRF Score: {result.rrf_score:.4f}")
print(f" Content: {result.content[:80]}...")
print()
Common Mistakes
❌ Wrong: Using multi-query for all searches
results = await memory.search(
owner_id="workspace-1",
query_text="iPhone 14 Pro",
query_expansion=True,
limit=10
)
✅ Right: Use selectively for complex queries
if is_complex_query(query_text):
query_expansion = True
else:
query_expansion = False
results = await memory.search(
owner_id="workspace-1",
query_text=query_text,
query_expansion=query_expansion,
limit=10
)
❌ Wrong: Too many query variants
results = await memory.search(
owner_id="workspace-1",
query_text="test",
query_expansion=True,
max_query_variants=10,
limit=10
)
✅ Right: Use 2-4 variants for balance
results = await memory.search(
owner_id="workspace-1",
query_text="test",
query_expansion=True,
max_query_variants=3,
limit=10
)
❌ Wrong: Ignoring latency requirements
@app.get("/autocomplete")
async def autocomplete(q: str):
results = await memory.search(
owner_id="workspace-1",
query_text=q,
query_expansion=True,
limit=5
)
return results
✅ Right: Consider latency constraints
@app.get("/autocomplete")
async def autocomplete(q: str):
results = await memory.search(
owner_id="workspace-1",
query_text=q,
query_expansion=False,
limit=5
)
return results
@app.get("/search")
async def search(q: str):
results = await memory.search(
owner_id="workspace-1",
query_text=q,
query_expansion=True,
max_query_variants=3,
limit=20
)
return results
Query Variant Generation Strategies
Multi-query uses heuristic rules to generate variants, not LLM-based expansion:
1. Keyword Variant (Stopword Removal)
Removes common stopwords to focus on key terms:
from llmemory.query_expansion import DEFAULT_STOPWORDS
Enabled by default via config.search.include_keyword_variant = True.
2. OR Variant (Boolean Expansion)
Creates Boolean OR of all non-stopword terms to maximize recall:
Only generated for multi-word queries. Widens recall by matching documents containing ANY of the key terms.
3. Quoted Phrase Variant (Exact Match)
Wraps the query in quotes for exact phrase matching:
Only generated for multi-word queries. Ensures high precision by requiring exact phrase match.
Complete Example
from llmemory.query_expansion import QueryExpansionService
from llmemory.config import SearchConfig
service = QueryExpansionService(SearchConfig())
variants = service._heuristic_variants(
"how to improve the customer satisfaction",
include_keywords=True
)
variants = service._heuristic_variants(
"machine learning deployment",
include_keywords=True
)
Advanced: Custom LLM-Based Expansion
The default implementation uses heuristics, but you can provide a custom LLM callback:
from llmemory.query_expansion import QueryExpansionService, ExpansionCallback
async def my_llm_expander(query: str, max_variants: int) -> list[str]:
"""Custom LLM-based query expansion."""
variants = await my_llm.generate_variants(query, max_variants)
return variants
service = QueryExpansionService(
search_config=config.search,
llm_callback=my_llm_expander
)
When llm_callback is provided, it's tried first; heuristics are used as fallback if LLM fails.
Advanced Patterns
Conditional Multi-Query
def should_expand_query(query_text: str) -> tuple[bool, int]:
"""Decide if query needs expansion and how many variants."""
if len(query_text.split()) <= 3:
return True, 4
question_words = ["how", "what", "why", "when", "where", "who"]
if any(word in query_text.lower() for word in question_words):
return True, 3
if any(char.isdigit() for char in query_text):
return False, 1
if '"' in query_text:
return False, 1
return True, 2
query = "how to improve performance"
expand, variants = should_expand_query(query)
results = await memory.search(
owner_id="workspace-1",
query_text=query,
query_expansion=expand,
max_query_variants=variants,
limit=10
)
A/B Testing Query Expansion
import random
use_expansion = random.random() < 0.5
results = await memory.search(
owner_id="workspace-1",
query_text=query_text,
query_expansion=use_expansion,
limit=10
)
Related Skills
basic-usage - Core search operations
hybrid-search - Vector + text hybrid search fundamentals
rag - Using multi-query in RAG systems
multi-tenant - Multi-tenant isolation patterns
Important Notes
Expansion Modes:
- Heuristic (default): Keyword extraction, OR clauses, phrase matching. Fast, no API calls.
- LLM (configurable): Semantic variants via GPT-4o-mini. Set
query_expansion_model in config.
No LLM Required for Default:
Query expansion works out-of-the-box with heuristic rules. No API key or LLM calls needed unless you configure query_expansion_model.
Cost Considerations (LLM mode only):
LLM expansion makes 1 API call per search. For high-volume applications with LLM expansion, consider:
- Caching common queries and their variants
- Using smaller models (gpt-4o-mini is fast and cheap)
- Enabling only for specific use cases
- Hybrid: Use heuristics for autocomplete, LLM for main search
Quality vs Speed:
- Heuristic: <1ms overhead, lexical diversity only
- LLM: 50-200ms overhead, semantic diversity
Fallback Behavior:
If LLM expansion fails or times out (8s), system automatically falls back to heuristic expansion. Search always completes.