Files
poimen-memory/tasks/M8.6-query-endpoint-upgrade.md
T
Story Crater Bot 959c596b1d chore: Archive completed task files (M0, M1, M3, M3.5, M4.1-2, M3.6.1)
Deleted 31 completed task files:
- M0.x: 8 tasks (cargo, domain types, recordsource, tokenizer, adapters, gate)
- M1.x: 8 tasks (llm-chat, standing-query, prompt template, parser, loop, log, e2e, gate)
- M3.x: 4 tasks (l2-synthesis, rerank, mem-query, gate)
- M3.5.x: 8 tasks (http-server, ingest, query, federation, skills, projects, rate-limiting, gate)
- M3.6.1: DocCorpusSource (heading-boundary chunking)
- M4.1-2: skill-draft, derived-filter

Updated INDEX.md:
- Removed M0 & M1 phase sections (archived in git history)
- Updated progress table: 65 active tasks (42 + 2🟡 + 21)
- Updated status: M0/M1 complete, M3/M3.5 gates passing, M4.1-2 done
- Noted M3.5.10 JWT auth implementation complete (awaiting image rollout)
- Cleaned up broken links to deleted task files

Total test count: 239 passing, 2 ignored (up from 196 at M3.4)
Ready for M4.3 gate composition, M5 post-training, M7 source connectors.
2026-08-27 20:25:05 -07:00

4.8 KiB
Raw Blame History

M8.6 — Upgrade GET /memory/query to hybrid with fallback

Field Value
Phase M8 — Hybrid Search
Size M — 12 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:

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:

pub struct AppState {
    // ... existing fields ...
    pub hybrid_query_worker: Option<Arc<HybridQueryWorker>>,  // None if OpenSearch not configured
}

Constructed in start_server():

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

# 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