# M8.6 — Upgrade GET /memory/query to hybrid with fallback | Field | Value | |---|---| | Phase | M8 — Hybrid Search | | Size | M — 1–2 days | | Status | ✅ COMPLETE | | Flags | — | | Spec | inlined below | | Blocks | M8.8, M8.9 | | Depends | M8.3, M8.4 (query optimizer, RRF fusion complete) | ## Goal Replace the `QueryWorker` call in `GET /memory/query` with `HybridQueryWorker`. Add `?method=` parameter for explicit strategy override. Implement fallback chain: hybrid → semantic → error. ## Design **Updated request:** ``` GET /memory/query?query=...&project=...&limit=10&method=hybrid Authorization: Bearer ``` New parameter: - `method` (optional) — `hybrid` (default), `semantic`, `lexical`. If omitted, `QueryOptimizer` decides. **Handler logic:** ```rust async fn query_handler(...) -> HttpResponse { let (claims, token) = validate_auth(...)?; check_capability(&claims, "memory:read")?; check_rate_limit(&claims, &state, "/memory/query")?; let method_override = query.get("method").map(|m| match m.as_str() { "semantic" => SearchStrategy::SemanticOnly, "lexical" => SearchStrategy::LexicalOnly, _ => SearchStrategy::Hybrid, // includes "hybrid" and unknown values }); // Try hybrid worker first if let Some(ref hybrid) = state.hybrid_query_worker { match hybrid.query(&project, &question, limit, &token, method_override).await { Ok(response) => return HttpResponse::Ok().json(response), Err(e) => { tracing::warn!("hybrid query failed, falling back: {}", e); } } } // Fallback: existing semantic-only worker match state.query_worker.query(&project, &question, Some(limit)).await { Ok(results) => HttpResponse::Ok().json(json!({ "query": question, "project": project, "search_strategy": "semantic_fallback", "results": results, })), Err(e) => HttpResponse::InternalServerError().json(json!({"error": "query_failed"})), } } ``` **AppState change:** ```rust pub struct AppState { // ... existing fields ... pub hybrid_query_worker: Option>, // None if OpenSearch not configured } ``` Constructed in `start_server()`: ```rust let hybrid = if std::env::var("OPENSEARCH_HOSTS").is_ok() { let os_client = OpenSearchClient::new(hosts); Some(Arc::new(HybridQueryWorker::new(vector_store, embeddings, Some(Arc::new(os_client))))) } else { // No OpenSearch configured — hybrid worker without lexical Some(Arc::new(HybridQueryWorker::new(vector_store, embeddings, None))) }; ``` **Backward compatibility:** If `OPENSEARCH_HOSTS` is not set, the worker still works but always uses `SemanticOnly`. Existing clients see the same results with an added `search_strategy` field. ## Steps 1. Add `hybrid_query_worker` field to `AppState`. 2. Construct `HybridQueryWorker` in `start_server()`, gated on `OPENSEARCH_HOSTS` env. 3. Update `query_handler()` with method override + fallback chain. 4. Add `?method=` query parameter parsing. 5. Update response format to include `search_strategy` and `metrics`. 6. Write integration test: query with `method=hybrid`, verify response has `metrics`. 7. Write integration test: query without OpenSearch, verify fallback to semantic. ## Acceptance 1. `GET /memory/query?query=test&project=p` returns `"search_strategy": "Hybrid"` when OpenSearch configured. 2. `GET /memory/query?query=test&project=p&method=semantic` forces semantic-only, response says `"search_strategy": "SemanticOnly"`. 3. If OpenSearch is down, query still succeeds with `"search_strategy": "semantic_fallback"`. 4. Response includes `metrics` block with timing data. 5. Existing clients that don't send `method` param get the same results as before (backward compat). 6. JWT token is forwarded to OpenSearch (not a new token, not admin credentials). ## Verify ```bash # With OpenSearch running curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:8080/memory/query?query=kubernetes+port&project=poimen" | jq .search_strategy # Should output: "Hybrid" # Force semantic curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:8080/memory/query?query=kubernetes+port&project=poimen&method=semantic" | jq .search_strategy # Should output: "SemanticOnly" # Kill OpenSearch, retry curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:8080/memory/query?query=kubernetes+port&project=poimen" | jq .search_strategy # Should output: "semantic_fallback" ``` **False pass:** Handler catches the hybrid error silently and always falls back to semantic — user never sees hybrid results even when OpenSearch is healthy. Assert that with OpenSearch up, `metrics.lexical_candidates` is `Some(n)` where `n > 0`. ## Artifacts - Modified `crates/mem-cli/src/http_server.rs`