Task — engineering-spec@1

"KB BGE-rerank-v2-m3 cross-encoder — reranks top-K results from TASK-KB-004 lexical + TASK-KB-005 semantic to final ordering"

draftTASK-KB-006
module kb · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-AI-020, TASK-KB-005 · blocks TASK-KB-007

§1 — Description (BCP-14 normative)

The KB service MUST ship reranker at services/kb/src/rerank/ using BGE-rerank-v2-m3 cross-encoder over TASK-KB-004/005 candidates, hybrid merge, 5min cache, 3 memory audit kinds.

  1. MUST validate rerank_source against closed enum per DEC-1931.
  1. MUST call BGE-rerank-v2-m3 at bge_rerank_client.rs::rerank(query, candidates) → Vec<(chunk, score)> per DEC-1930.
  1. MUST merge hybrid per DEC-1932 at hybrid_merger.rs::merge(lexical_results, semantic_results):
  1. MUST cache per DEC-1933: ``sql CREATE TABLE kb_rerank_cache ( cache_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, query_hash CHAR(64) NOT NULL, -- SHA256(query) source TEXT NOT NULL CHECK (source IN ('lexical_only','semantic_only','hybrid_lexical_semantic','manual_curation')), results_jsonb JSONB NOT NULL, cached_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL, UNIQUE (tenant_id, query_hash, source) ); CREATE INDEX rerank_cache_expiry_idx ON kb_rerank_cache(expires_at); ALTER TABLE kb_rerank_cache ENABLE ROW LEVEL SECURITY; CREATE POLICY rerank_cache_rls ON kb_rerank_cache USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); GRANT DELETE ON kb_rerank_cache TO cyberos_app; ``
  1. MUST expose endpoint: ``text POST /v1/kb/search/rerank body: {query, source: hybrid_lexical_semantic|lexical_only|semantic_only} ``
  1. MUST emit 3 memory audit kinds per DEC-1934. PII per TASK-MEMORY-111: query SHA256 in chain; results count ok.
  1. MUST thread trace_id from query → rerank → cache → audit.
  1. MUST NOT return >10 results per DEC-1932.
  1. MUST NOT bypass cache per DEC-1933 (perf invariant — same query+source within 5min must hit cache).
  1. MUST evict cache rows past expires_at via TASK-MCP-007 nightly cron.

§2 — Why this design

Why cross-encoder (DEC-1930)? Cross-encoders score query+candidate jointly; far more accurate than dual-encoder similarity.

Why hybrid (DEC-1932)? Lexical catches exact keywords; semantic catches paraphrase. Union maximises recall.

Why 5min cache (DEC-1933)? Cross-encoder calls are expensive (~200ms); same-query repeats common (user iterates on search).

Why top-10 result (DEC-1932)? UX: users scan first 10; longer lists ignored. Cross-encoder cost justifies cap.


§3 — API contract

POST /v1/kb/search/rerank

Sample request:

{
  "query": "how do I emit hóa đơn",
  "source": "hybrid_lexical_semantic"
}

Sample response:

{
  "results": [
    {
      "chunk_id": "uuid",
      "doc_id": "uuid",
      "doc_title": "Quy trình xuất hóa đơn",
      "snippet": "...",
      "rerank_score": 0.94,
      "source": "hybrid_lexical_semantic"
    }
  ],
  "from_cache": false,
  "rerank_duration_ms": 180
}

§4 — Acceptance criteria

  1. rerank_source enum cardinality 4. 2. BGE-rerank-v2-m3 cross-encoder. 3. Hybrid: lexical+semantic top-20 each, rerank top-40, return top-10. 4. Lexical-only mode. 5. Semantic-only mode. 6. Manual curation passthrough. 7. 5min cache TTL. 8. Cache hit returns from_cache=true. 9. 3 memory audit kinds emitted. 10. PII scrubbed (query SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. Cache expiry cron. 14. UNIQUE(tenant_id, query_hash, source). 15. Append-only via REVOKE except DELETE. 16. Rerank duration < 300ms p95. 17. Dedup by chunk_id + doc_id. 18. Empty candidates returns empty. 19. AI-020 service down → fallback to candidate order + sev-2 audit. 20. Score in 0-1 range.

§5 — Verification

#[tokio::test]
async fn hybrid_rerank_returns_top_10() {
    let ctx = TestContext::with_50_indexed_docs().await;
    let r = ctx.rerank("test", "hybrid_lexical_semantic").await;
    assert_eq!(r.results.len(), 10);
}

#[tokio::test]
async fn cache_hit_returns_from_cache() {
    let ctx = TestContext::with_docs().await;
    let r1 = ctx.rerank("query", "hybrid_lexical_semantic").await;
    assert_eq!(r1.from_cache, false);
    let r2 = ctx.rerank("query", "hybrid_lexical_semantic").await;
    assert_eq!(r2.from_cache, true);
}

#[tokio::test]
async fn dedup_across_sources() {
    let ctx = TestContext::with_doc_matching_both().await;
    let r = ctx.rerank("term", "hybrid_lexical_semantic").await;
    let doc_ids: HashSet<_> = r.results.iter().map(|x| x.doc_id).collect();
    assert_eq!(doc_ids.len(), r.results.len());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-AI-020 (BGE-rerank-v2-m3 service), TASK-KB-005. Downstream: TASK-KB-007 (Ask this page Q&A). Cross-module: TASK-KB-004 (lexical input), TASK-MCP-007 (cache eviction cron), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
BGE-rerank service downcatchfallback candidate order + sev-2retry
Cache table corruptionDELETE + re-rerankinherentinherent
Query > 500 charsvalidate400shorten
Empty candidatesinherent[]inherent
Score outside 0-1clampinherentinherent
Cross-tenant cacheRLS0 rowsinherent
Expired cache servedexpires_at checkre-rerankinherent
Concurrent rerank same queryUNIQUEfirst winsinherent
Manual curation list invalidvalidatereject 400fix list
Result enrichment fail (chunk → doc)catchsev-3 + skipdata fix

§11 — Implementation notes


End of TASK-KB-006 spec.