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.
4.9 KiB
M8.5 — Hybrid query worker: parallel retrieval + fusion
| Field | Value |
|---|---|
| Phase | M8 — Hybrid Search |
| Size | L — 2–3 days |
| Status | ⬜ |
| Flags | — |
| Spec | inlined below |
| Blocks | M8.6, M8.7 |
| Depends | M8.1 (OpenSearch running), M8.2 (dual-write), M8.3 (query optimizer), M8.4 (RRF) |
Goal
HybridQueryWorker orchestrates parallel retrieval from pgvector and OpenSearch, applies RRF fusion, and returns ranked results with score breakdown and latency metrics. It replaces QueryWorker as the primary query engine behind GET /memory/query.
Design
Execution flow:
- Call
QueryOptimizer::optimize_query()→ getQueryContextwithSearchStrategy. - Generate embedding via
EmbeddingsClient::embed(). - Execute strategy:
- Hybrid:
tokio::try_join!pgvector top-50 + OpenSearch top-50. Fuse with RRF. - LexicalFirst: OpenSearch top-200 → extract IDs → pgvector
WHERE id IN (...)top-10. - SemanticOnly: pgvector top-50 (fallback if OpenSearch unavailable).
- LexicalOnly: OpenSearch top-50 (fallback if embedding model unavailable).
- Hybrid:
- Build response with score breakdown.
Critical: use actual VectorStore API.
The existing VectorStore exposes search_l1(project, &embedding, limit) and search_l2(project, &embedding). The worker must call these — not invented methods.
For the cascading strategy (LexicalFirst), a new search_l1_by_ids(project, &embedding, limit, &[chunk_id]) method is needed on VectorStore. This is a filtered pgvector query:
SELECT id, 1 - (embedding <=> $1) as score, text, source
FROM chunks
WHERE project = $2 AND id = ANY($3)
ORDER BY embedding <=> $1
LIMIT $4
OpenSearch query:
{
"size": 50,
"query": {
"bool": {
"must": [{
"multi_match": {
"query": "...",
"fields": ["content^2", "section_title^1.5", "breadcrumb", "source"],
"type": "best_fields",
"fuzziness": "AUTO"
}
}],
"filter": [
{"term": {"project_id": "..."}},
{"terms": {"level": ["L0", "L1", "L2"]}}
]
}
}
}
Response struct:
pub struct HybridQueryResponse {
pub query: String,
pub project: String,
pub search_strategy: String,
pub results: Vec<HybridQueryResult>,
pub metrics: QueryMetrics,
}
pub struct HybridQueryResult {
pub id: String,
pub text: String,
pub source: String,
pub level: String,
pub breadcrumb: Vec<String>,
pub final_score: f32,
pub semantic_rank: Option<usize>,
pub lexical_rank: Option<usize>,
pub fusion_method: String,
}
pub struct QueryMetrics {
pub total_time_ms: u128,
pub semantic_time_ms: Option<u128>,
pub lexical_time_ms: Option<u128>,
pub fusion_time_ms: u128,
pub semantic_candidates: Option<usize>,
pub lexical_candidates: Option<usize>,
pub final_count: usize,
}
Steps
- Add
search_l1_by_ids()toVectorStore(new SQL query withid = ANY($3)). - Make
OpenSearchClient::lexical_search()public. - Implement
HybridQueryWorker::new()takingVectorStore,EmbeddingsClient,Option<OpenSearchClient>. - Implement
query()method with strategy dispatch. - Implement
retrieve_hybrid()usingtokio::try_join!. - Implement
retrieve_cascading()(2-stage). - Implement fallback methods (
retrieve_semantic(),retrieve_lexical()). - Wire RRF fusion into the result pipeline.
- Build response with per-result rank tracking.
- Write integration tests with mock VectorStore + mock OpenSearch.
Acceptance
- Hybrid query returns results from both engines — check
semantic_rankandlexical_rankare bothSomefor documents appearing in both lists. - Cascading query's pgvector call receives only IDs from the OpenSearch narrowing step — verify with query log or mock.
- If
OpenSearchClientisNone, strategy automatically falls back toSemanticOnly. - If embedding generation fails, strategy falls back to
LexicalOnly(if OpenSearch available) or returns error. metrics.total_time_msis populated and < 500ms for test fixtures.metrics.semantic_time_msandlexical_time_msare roughly equal (parallel execution, not serial).- Results are sorted by
final_scoredescending. final_count≤RRFConfig.final_k.
Verify
cargo test -p mem-cli hybrid_query -- --nocapture
False pass: Worker always returns semantic-only results even when strategy is Hybrid. Check by asserting lexical_rank.is_some() on at least one result when OpenSearch is configured. Another false pass: serial execution disguised as parallel — assert max(semantic_time_ms, lexical_time_ms) ≈ total - fusion_time_ms, not sum.
Artifacts
crates/mem-cli/src/hybrid_query_worker.rs(rewrite from current stub)- Modified
crates/mem-store/src/lib.rs(addsearch_l1_by_ids) - Modified
crates/mem-cli/src/opensearch_client.rs(makelexical_searchpub)