2026-08-27 20:25:05 -07:00
# M8.6 — Upgrade GET /memory/query to hybrid with fallback
| Field | Value |
|---|---|
| Phase | M8 — Hybrid Search |
| Size | M — 1– 2 days |
2026-08-28 13:30:30 -07:00
| Status | ✅ COMPLETE |
2026-08-27 20:25:05 -07:00
| Flags | — |
| Spec | inlined below |
| Blocks | M8.8, M8.9 |
2026-08-28 13:30:30 -07:00
| Depends | M8.3, M8.4 (query optimizer, RRF fusion complete) |
2026-08-27 20:25:05 -07:00
## 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 <JWT>
```
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 < Arc < HybridQueryWorker >> , // 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`