Files
poimen-memory/tasks/M3.5.3-query-endpoint.md
Story Crater Bot 631cbfa3e9 feat: complete M0.1-M0.4 phases
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
2026-08-22 23:13:42 -07:00

149 lines
6.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M3.5.3 — GET /query endpoint: HNSW recall, rerank, edge-walk to L0
| Field | Value |
|---|---|
| Phase | M3.5 — Distributed API Layer |
| Size | M — 13 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 text
- `project` (optional) — filter to one project; if omitted, search all projects
- `level` (optional, comma-separated) — `L1,L2` (default) or `L0,L1,L2`; filters by node level
- `limit` (optional, integer, default 5) — how many top results to return
- `timeout_seconds` (optional, integer, default 5) — abort if search exceeds this time
**Response:**
```json
{
"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
1. `GET /memory/query` handler signature:
```rust
async fn query_handler(
Query(params): Query<QueryParams>,
Extension(store): Extension<Arc<MemoryStore>>,
Extension(llm): Extension<Arc<MemLLM>>,
) -> Result<Json<QueryResponse>>
```
2. Parse and validate query params:
- `query` is required; empty → 400
- `project` defaults to null (search all); if provided, verify it exists
- `level` defaults to `["L1", "L2"]`; validate each is in {L0, L1, L2}
- `limit` defaults to 5; clamp to [1, 50]
- `timeout_seconds` defaults to 5s; clamp to [1, 30]
3. Embed the query (calls M2.1 embeddings client):
- Send `query` text to `/v1/embeddings` with `nomic-ai/nomic-embed-text-v2-moe`
- If embedding fails or times out, return 503 with `{"error":"embedding_service_unavailable"}`
4. 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)
5. Rerank (calls M3.2 rerank client):
- Collect top K=3×limit candidates (e.g., 15 for limit=5)
- Send to `/v1/rerank` with passages=candidates and query
- Parse `bge-reranker-base` response, extract score per candidate
- Compute `rerank_score = raw_score / 100` (reranker outputs [0,100])
6. Sort by rerank_score descending, take top `limit` results
7. Edge walk (L1→L0, L2→L1):
- For each result, query `memory_edge` to find parent nodes
- Fetch parent node text from `memory_node`
- Include in `parents` array (ordered by edge precedence if tracked, else by sha256)
8. 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`:
1. `a1_basic_query_returns_results` — GET /query?query=Kong+body returns 200 with `results` array.
2. `a2_scores_are_present` — result items include `query_score` and `rerank_score`, both floats in [0,1].
3. `a3_l0_parents_included` — L1 result has `parents` array containing L0 nodes.
4. `a4_level_filter_l0_only` — GET /query?level=L0 returns L0 nodes only (check level field).
5. `a5_level_filter_l1_l2` — GET /query?level=L1,L2 returns only L1 and L2 (no L0).
6. `a6_project_filter_works` — ingest into two projects, query with `project=poimen` — result.project matches.
7. `a7_limit_respected` — GET /query?limit=3 returns ≤3 results.
8. `a8_query_score_before_rerank` — query_score from HNSW comes before rerank; rerank_score ≤ query_score (reranker should not boost beyond HNSW recall).
9. `a9_timeout_enforced` — manually slow the embedding service (mock delay 10s), GET /query with `timeout_seconds=1` returns 503.
10. `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 `parents` passes 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](../DESIGN.md#distributed-api-layer-homelab-frontend)