Skip to main content

Phase 1-2: Query Rewriting & Multi-Search

Approach

Method: Deterministic Rule-based
No LLM Used

Purpose

Generate search query variations and execute parallel searches without LLM variability:

  • Query Rewriting (Phase 1): Create primary + fallback queries
  • Multi-Search (Phase 2): Execute parallel Convex searches
  • Filter Application: Apply budget and constraints
  • Candidate Collection: Gather ~1000+ initial candidates

Why Rule-Based?

Predictability

  • No LLM drift: Same input always produces same queries
  • Deterministic: Perfect for testing and debugging
  • Stable: No model updates breaking behavior
  • Controllable: Exact query logic is visible

Latency

  • Speed: <50ms total (vs moderate with LLM)
  • No API calls: Zero network overhead
  • Parallel: Multiple searches execute simultaneously
  • Cacheable: Query patterns can be memoized

Reliability

  • No hallucination: Queries stay within known vocabulary
  • No malformed output: Always valid search syntax
  • Error-free: Pure functions with guaranteed outputs
  • Repeatable: Critical for A/B testing

Cost

  • Zero LLM cost: No model inference
  • Only search cost: Convex query charges apply
  • Scalable: Linear cost with volume

Implementation

Query Rewriting (Phase 1)

Location: services/query-rewriting

function generateQueryVariations(
giftContext: GiftContext
): QueryVariation[] {
const variations: QueryVariation[] = [];

// Primary query: specific category + product type
if (giftContext.category && giftContext.productType) {
variations.push({
type: 'primary',
query: `${giftContext.category} ${giftContext.productType}`,
weight: 1.0
});
}

// Fallback 1: product type only
if (giftContext.productType) {
variations.push({
type: 'fallback',
query: giftContext.productType,
weight: 0.8
});
}

// Fallback 2: category hints
if (giftContext.categoryHints?.length) {
giftContext.categoryHints.forEach(hint => {
variations.push({
type: 'fallback',
query: hint,
weight: 0.6
});
});
}

return variations;
}

Multi-Search (Phase 2)

Location: services/product-search

async function executeMultiSearch(
queries: QueryVariation[],
filters: SearchFilters
): Promise<Product[]> {
// Execute all queries in parallel
const results = await Promise.all(
queries.map(q =>
convex.query(api.search.vectorSearch, {
query: q.query,
limit: 100,
filters: {
budget: filters.budget,
excludeIds: filters.excludeIds,
constraints: filters.constraints
}
})
)
);

// Merge and deduplicate
const uniqueProducts = deduplicateProducts(results.flat());

// Weight by query importance
return weightProducts(uniqueProducts, queries);
}

Query Patterns

For "Show More"

// Special case: use last search params
if (giftContext.intent === 'show_more_products') {
return {
query: lastSearchParams.queryForSearch,
categoryHints: lastSearchParams.categoryHints,
excludeIds: [...previousIds]
};
}
// Example: "sünnipäevakingitused"
queries = [
{ query: "Sünnipäev Kingitus", weight: 1.0 },
{ query: "Kingitus", weight: 0.8 },
{ query: "Kodu ja Aed", weight: 0.6 } // category hint
];
// Example: "Tolkien"
queries = [
{ query: "Tolkien Raamat", weight: 1.0 },
{ query: "J.R.R. Tolkien", weight: 1.0 },
{ query: "Fantaasia Ilukirjandus", weight: 0.7 } // genre fallback
];

Performance Characteristics

Filters Applied

interface SearchFilters {
// Budget constraints
budget?: {
min?: number;
max?: number;
};

// Exclusions
excludeIds: string[]; // Previously shown products

// Constraints
constraints?: string[]; // e.g., ['EXCLUDE_BOOKS', 'EXCLUDE_GIFT_CARDS']

// Language
bookLanguage?: 'et' | 'en'; // For books only

// Availability
inStock?: boolean;
}

Optimization Strategies

1. Query Deduplication

// Remove duplicate queries before search
const uniqueQueries = Array.from(
new Set(queries.map(q => q.query))
).map(query =>
queries.find(q => q.query === query)!
);

2. Early Termination

// Stop searching if enough high-quality results
if (candidates.length >= 100 && minScore > 0.8) {
return candidates.slice(0, 100);
}

3. Smart Fallbacks

// If primary query returns &lt;3 results, try fallbacks
if (primaryResults.length < 3) {
const fallbackResults = await executeFallbackQueries();
return [...primaryResults, ...fallbackResults];
}

Error Handling

async function safeMultiSearch(
queries: QueryVariation[]
): Promise<Product[]> {
try {
return await executeMultiSearch(queries);
} catch (error) {
console.error('Multi-search failed:', error);

// Fallback: single broad query
return await convex.query(api.search.vectorSearch, {
query: 'Kingitus', // Generic gift
limit: 50
});
}
}

Monitoring

Track in production:

{
queryCount: number, // Queries generated per request
searchTimeMs: number, // Total search duration
candidatesFound: number, // Products before filtering
deduplicationRate: number, // % removed as duplicates
fallbackTriggered: boolean, // Primary failed?

// Per-query metrics
queryMetrics: {
primary: { count: number, avgScore: number },
fallback: { count: number, avgScore: number }
}
}

Testing

describe('Query Rewriting', () => {
it('generates primary + fallback queries', () => {
const context = {
productType: 'Raamat',
category: 'Ilukirjandus'
};

const queries = generateQueryVariations(context);

expect(queries[0].query).toBe('Ilukirjandus Raamat');
expect(queries[1].query).toBe('Raamat');
expect(queries.length).toBeGreaterThanOrEqual(2);
});
});

describe('Multi-Search', () => {
it('executes queries in parallel', async () => {
const queries = [
{ query: 'Raamat', weight: 1.0 },
{ query: 'Ilukirjandus', weight: 0.8 }
];

const start = Date.now();
const results = await executeMultiSearch(queries);
const duration = Date.now() - start;

expect(duration).toBeLessThan(300); // Parallel speedup
expect(results.length).toBeGreaterThan(0);
});
});

Alternatives Considered

LLM-based Query Rewriting

Why Not:

  • Latency: Adds moderate
  • Cost: $0.10-0.20 per 1000 requests
  • Variability: Same context → different queries
  • Debugging: Hard to trace query decisions

Single Query Approach

Why Not:

  • Recall: Misses relevant products
  • No fallback: Fragile to category mismatch
  • Lower quality: Fewer candidates for reranking