128 lines
4.8 KiB
Markdown
128 lines
4.8 KiB
Markdown
# M8.6 — Upgrade GET /memory/query to hybrid with fallback
|
||||
|
|
|
|||
|
|
| Field | Value |
|
|||
|
|
|---|---|
|
|||
|
|
| Phase | M8 — Hybrid Search |
|
|||
|
|
| Size | M — 1–2 days |
|
|||
|
|
| Status | ⬜ |
|
|||
|
|
| Flags | — |
|
|||
|
|
| Spec | inlined below |
|
|||
|
|
| Blocks | M8.8, M8.9 |
|
|||
|
|
| Depends | M8.5 (hybrid query worker compiles and passes tests) |
|
|||
|
|
|
|||
|
|
## 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`
|