M0.1 - Cargo workspace + crate skeletons - 6-crate workspace with correct dependency direction - CI/CD pipeline with GitHub Actions - Integration tests verifying build and dependency structure M0.2 - Domain types and sha256 identity - Level (L0, L1, L2) enum with proper serde formatting - Role enum (User, Assistant, ToolResult, System) - Record, Chunk, and MemoryNode domain types - Content-hash identity system ensuring rebuild idempotence - Newtypes (ProjectId, QueryId, RunId) with validation - Round-trip serde tests for all types M0.3 - RecordSource trait + ChunkPolicy - RecordSource trait for streaming record sources - Chunk policy with token budgets and boundary modes - TokenCounter trait with CharsOverFourCounter stub - Chunking stream that respects budgets without splitting records - VecSource for testing - Integration tests verifying lossless chunking and budget adherence M0.4 - Tokenizer-backed chunk sizing - Vendored Qwen2 tokenizer with hash verification - QwenTokenCounter implementing proper token counting - Hash guard that fails on modified tokenizer - mem tokens CLI subcommand for token counting - Integration tests with known string counts, hash guards, and budget verification Total: 19 integration tests passing, all phases verified to compose correctly Workspace builds cleanly with no clippy warnings
6.3 KiB
M3.5.3 — GET /query endpoint: HNSW recall, rerank, edge-walk to L0
| Field | Value |
|---|---|
| Phase | M3.5 — Distributed API Layer |
| Size | M — 1–3 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M3.5.4, M3.5.8 |
| Depends | M3.5.1, M3.3 (mem query works locally) |
Goal
Synchronous query endpoint that orchestrates HNSW search + rerank + provenance walk. Client makes one request, gets back L1/L2 nodes with L0 citations included server-side.
Design
Request:
GET /memory/query?query=why+did+requests+over+10KB+fail&project=poimen&level=L1,L2&limit=5
Query params:
query(required, URL-encoded) — user question or search textproject(optional) — filter to one project; if omitted, search all projectslevel(optional, comma-separated) —L1,L2(default) orL0,L1,L2; filters by node levellimit(optional, integer, default 5) — how many top results to returntimeout_seconds(optional, integer, default 5) — abort if search exceeds this time
Response:
{
"query": "why did requests over 10KB fail",
"project": "poimen",
"level_filter": ["L1", "L2"],
"results": [
{
"level": "L1",
"sha256": "abc...",
"text": "Kong body buffer was 8MB...",
"query_score": 0.92,
"rerank_score": 0.94,
"parents": [
{
"level": "L0",
"sha256": "xyz...",
"source": "pi:2026-07-21-019f857d",
"text": "...Kong body buffer limit...",
"timestamp": "2026-07-21T16:23:59Z"
}
]
},
...
],
"latency_ms": 342,
"notes": "3 results found; reranker reduced from 12 HNSW candidates"
}
Steps
-
GET /memory/queryhandler signature:async fn query_handler( Query(params): Query<QueryParams>, Extension(store): Extension<Arc<MemoryStore>>, Extension(llm): Extension<Arc<MemLLM>>, ) -> Result<Json<QueryResponse>> -
Parse and validate query params:
queryis required; empty → 400projectdefaults to null (search all); if provided, verify it existsleveldefaults to["L1", "L2"]; validate each is in {L0, L1, L2}limitdefaults to 5; clamp to [1, 50]timeout_secondsdefaults to 5s; clamp to [1, 30]
-
Embed the query (calls M2.1 embeddings client):
- Send
querytext to/v1/embeddingswithnomic-ai/nomic-embed-text-v2-moe - If embedding fails or times out, return 503 with
{"error":"embedding_service_unavailable"}
- Send
-
HNSW recall (calls pgvector):
SELECT sha256, level, text, embedding <-> query_embedding AS distance FROM memory_node WHERE level = ANY($1) AND (project = $2 OR $2 IS NULL) ORDER BY distance ASC LIMIT $3- Use distance metric
vector_cosine_ops(similarity = 1 - distance) - Compute
query_score = 1 - distance - Return candidates (no reranking yet)
-
Rerank (calls M3.2 rerank client):
- Collect top K=3×limit candidates (e.g., 15 for limit=5)
- Send to
/v1/rerankwith passages=candidates and query - Parse
bge-reranker-baseresponse, extract score per candidate - Compute
rerank_score = raw_score / 100(reranker outputs [0,100])
-
Sort by rerank_score descending, take top
limitresults -
Edge walk (L1→L0, L2→L1):
- For each result, query
memory_edgeto find parent nodes - Fetch parent node text from
memory_node - Include in
parentsarray (ordered by edge precedence if tracked, else by sha256)
- For each result, query
-
Assemble response and return 200
Acceptance
- Query with valid text returns results
- Results include query_score and rerank_score
- L0 parents are walked and included
- Different level filters change result count (e.g., L0 only returns more results)
- Timeout parameter is respected
- Query too short (e.g., single char) handled gracefully (400 or empty result, not crash)
Verify
Harness: Integration tests against server + pgvector repo populated with known nodes.
Setup: Load tests/fixtures/memory_nodes.jsonl into test pgvector DB before each test. Nodes include L0 (evidence), L1 (per-query memory), and L2 (synthesis) with known text and relationships.
Integration test — tests/it_query_endpoint.rs:
a1_basic_query_returns_results— GET /query?query=Kong+body returns 200 withresultsarray.a2_scores_are_present— result items includequery_scoreandrerank_score, both floats in [0,1].a3_l0_parents_included— L1 result hasparentsarray containing L0 nodes.a4_level_filter_l0_only— GET /query?level=L0 returns L0 nodes only (check level field).a5_level_filter_l1_l2— GET /query?level=L1,L2 returns only L1 and L2 (no L0).a6_project_filter_works— ingest into two projects, query withproject=poimen— result.project matches.a7_limit_respected— GET /query?limit=3 returns ≤3 results.a8_query_score_before_rerank— query_score from HNSW comes before rerank; rerank_score ≤ query_score (reranker should not boost beyond HNSW recall).a9_timeout_enforced— manually slow the embedding service (mock delay 10s), GET /query withtimeout_seconds=1returns 503.a10_empty_query_returns_400— GET /query (no query param) or GET /query?query= returns 400.
Command: cargo test -p mem-cli query_endpoint
False pass:
- Testing only the happy path. Timeout, missing parent, embedding failure — all return different error codes.
- Results sorted by query_score, not rerank_score. Reranking must reorder the results.
- Parent nodes fetched but never asserted. A result with empty
parentspasses all checks. - query_score computed correctly but rerank_score always zero. Both must be present and in [0,1].
Traps
- Timeout is wall-clock time, not per-service timeout. A 5s timeout that calls embedding (200ms) + HNSW (100ms) + rerank (500ms) should complete in <5s total, not each. Use
tokio::time::timeout()around the entire handler. - HNSW uses
<->operator for cosine distance (0 = opposite, 1 = same). 1 - distance is correct for similarity; do not invert again. - Reranker scores are [0,100]; dividing by 100 gives [0,1]. Not dividing is a common bug.
- Embedding cache: the same query text submitted twice should reuse the embedding (save 200ms). Easy to forget.
Background: DESIGN.md § Distributed API Layer