auralynq-rag / DECISIONS.md
MHamdan's picture
Deploy Auralynq RAG (Llama-3.3-70B via HF Inference Providers)
8c1b9fe verified
|
Raw
History Blame Contribute Delete
30 kB

Architecture Decision Records β€” Auralynq

Short ADRs. Each: Context β†’ Decision β†’ Rationale β†’ Alternatives rejected.


ADR-0001 β€” Product & package naming

Context. The product must use one consistent name across package, CLI, services, UI, and docs.

Decision. Product = Auralynq. Python package / modules / folders / CLI / services = auralynq. MCP server = auralynq-mcp. Container prefix = auralynq-*. Tagline = "Talk to Your Data". PathRAG is preserved only as the name of the graph-retrieval algorithm/paper.

Rationale. Single source of truth prevents drift; a repo-wide name audit (make name-audit) enforces it.

Alternatives rejected. Mixed/generic naming (e.g. a "Voice-RAG"-style label, or branding the product after the PathRAG algorithm) β€” causes branding drift and import confusion.


ADR-0002 β€” Python 3.11 baseline (spec asked 3.12)

Context. Spec requests Python 3.12; the build/CI environment ships Python 3.11.14.

Decision. Target requires-python = ">=3.11". Code is written to be 3.11/3.12 compatible; CI matrices both.

Rationale. Use the interpreter actually present so make setup/tests run at $0 with no extra downloads. No 3.12-only syntax is used.

Alternatives rejected. Forcing a 3.12 toolchain install β€” slower setup, no functional benefit for this codebase.


ADR-0003 β€” Lightweight core + optional heavy extras + deterministic fallbacks

Context. The spec mandates "runs locally at $0", "missing paid keys never break the demo", yet also wants bge-m3, Qdrant, whisper, pyannote, Kokoro, langgraph, ragas, etc. β€” multi-GB GPU stacks.

Decision. Core deps are pure-Python/light (pydantic, fastapi, numpy, networkx, httpx). Every heavy/paid integration lives in a pyproject extra and is imported lazily. Each subsystem ships a deterministic, dependency-free fallback:

  • Embeddings β†’ hashing-bag embedder (stable, seeded) when bge-m3 absent.
  • Vector store β†’ in-process MemoryStore when qdrant-client/server absent.
  • LLM β†’ extractive synthesizer (citation-faithful) when Ollama/keys absent.
  • ASR/TTS β†’ null/sine + transcript-passthrough providers when models absent.
  • Agent β†’ native Python state machine mirroring the LangGraph graph when langgraph absent.
  • Eval β†’ numpy metric implementations when ragas/jiwer absent.

Rationale. The whole platform β€” ingest β†’ index β†’ retrieve β†’ agent β†’ serve β†’ eval β†’ MCP β†’ UI β€” is exercisable and unit-tested offline. Upgrading to real models is a config flag, not a code change. This is the single most important design choice for reproducibility.

Alternatives rejected. Hard-requiring the ML stack β€” breaks $0 guarantee, makes CI heavy/flaky, slows onboarding.


ADR-0004 β€” Provider abstraction via factories + auto resolution

Context. Many swappable backends (embeddings, LLM, rerank, ASR, TTS, vector).

Decision. Each subsystem exposes a Protocol/ABC and a factory.py that resolves provider=auto by probing env keys and importable packages, falling back deterministically. Selection is logged once at startup.

Rationale. One config surface; testable resolution logic; honest "what's actually running" reporting at /health.

Alternatives rejected. Hard-wired imports; conditional logic scattered across call-sites.


ADR-0005 β€” Podman-first containerization

Context. Target machine has no Docker; Podman 3.4 with podman-compose (the podman compose subcommand is absent).

Decision. scripts/check_container_runtime.sh detects podman compose then podman-compose; all Makefile stack targets call it. Compose file is compose.yml. Images are rootless-friendly (no privileged ports, named volumes).

Rationale. Matches the actual environment; degrades gracefully with a clear message if neither is present.

Alternatives rejected. Docker / docker-compose β€” not installed, explicitly disallowed.


ADR-0006 β€” PathRAG isolated, attributed, flow-pruned

Context. Graph retrieval must implement PathRAG (Chen et al., 2025) with flow-based pruning.

Decision. PathRAG lives only under auralynq/retrieval/pathrag/. We implement node retrieval β†’ relational path expansion (bounded hops) β†’ resource-flow-based path pruning β†’ path-reliability scoring β†’ deterministic path-to-text prompting with golden-region (most-reliable-at-edges) ordering. The algorithm is a clean-room implementation from the paper; attribution + license notes in THIRD_PARTY.md.

Rationale. Clear module boundary, testable scoring, no licensing entanglement.

Alternatives rejected. Vendoring a third-party repo wholesale (license/maintenance risk).


ADR-0007 β€” Relational knowledge graph in NetworkX + SQLite-style JSON persistence

Context. Need entities, relations, provenance, spans, timestamps, reliability β€” small/local.

Decision. Build the KG with networkx.MultiDiGraph; persist as versioned JSON (graph.json) plus a node/edge provenance table. Reliability scores are computed from corroboration count + source trust.

Rationale. Zero-infra, diff-able, fast for laptop-scale corpora; trivially swappable for a real graph DB later.

Alternatives rejected. Neo4j (infra weight), pure in-memory (no persistence).


ADR-0008 β€” Agent as explicit typed state machine

Context. Spec lists 11 nodes with latency budgets and iteration caps.

Decision. A single Pydantic AgentState flows through nodes. When langgraph is installed we compile a real StateGraph; otherwise an equivalent ordered executor runs the same node functions. Every node emits a trace span with timing; the trajectory is returned in the API response and rendered in the UI.

Rationale. Identical behavior with/without langgraph; full observability; deterministic tests.

Alternatives rejected. langgraph as a hard dependency (heavy, breaks $0 default).


ADR-0009 β€” SSE for chat tokens, WebSocket for voice

Context. Need token streaming and a bidirectional voice channel.

Decision. Chat uses Server-Sent Events (sse-starlette) β€” simple, proxy-friendly, unidirectional. Voice uses a WebSocket carrying control + audio frames for the push-to-talk loop.

Rationale. Right tool per channel; SSE avoids WS overhead for plain token streams.

Alternatives rejected. WS for everything (more complex client), polling (latency).


ADR-0010 β€” Eval harness owns all benchmark numbers

Context. README must not contain invented numbers.

Decision. Every metric in the README is produced by make eval / make bench and written under reports/. Unmeasured metrics are explicitly marked "pending". A frozen golden set + drift check guards regressions.

Rationale. Honesty + reproducibility; the README is generated from real artifacts.

Alternatives rejected. Hand-written marketing numbers.


ADR-0011 β€” Optional bearer-token API auth (open-by-default)

Context. The serving layer needs a production auth story, but the headline promise is a zero-friction local $0 demo. These pull in opposite directions.

Decision. Auth is gated by AURALYNQ_SERVE__API_KEY. Empty (default) β‡’ the API is open β€” the local demo and tests are unaffected. When set, an AuthMiddleware requires Authorization: Bearer <key> on all endpoints except /health and /metrics (and CORS pre-flight), returning a structured 401. Tokens are compared with hmac.compare_digest (constant-time).

Rationale. One env var flips a local demo into an authenticated deployment with no code change; health/metrics stay public for orchestrators/probes; constant-time compare avoids timing leaks.

Alternatives rejected. Always-on auth (breaks the $0 demo), per-route decorators (scattered, easy to miss an endpoint), full OAuth/JWT (over-engineered for a single-tenant local-first tool; can layer on later behind the same middleware).


ADR-0012 β€” Deployment hardening for remote/server exposure

Context. The stack is run on a remote server reachable by browsers on other machines, with all ports bound to 0.0.0.0. The earlier setup ran api/worker as container-root, exposed Qdrant + Phoenix publicly with no auth, had no restart policies/healthchecks, and (for rootless DNS reasons) reached Qdrant via the host gateway. Enabling API auth naively would either break the browser UI or leak the key into the client bundle.

Decision.

  • Network surface: only the web UI and API publish on 0.0.0.0. Qdrant and Phoenix bind to ${AURALYNQ_BIND_INTERNAL:-127.0.0.1} β€” on this host the podman bridge gateway (10.88.0.1/cni-podman0), reachable by containers (via host.containers.internal) and host tooling, but not from the external NIC. Default 127.0.0.1 keeps a clean host secure out of the box.
  • API auth: AURALYNQ_SERVE__API_KEY (empty == open). The browser never holds the key: the web container runs a same-origin /api/* proxy (Next.js route handler) that injects Authorization: Bearer server-side and streams responses. So NEXT_PUBLIC_API_BASE=/api β€” no IP and no secret are baked into the bundle.
  • Least privilege: api/worker drop the user: "0:0" override and run as the image's non-root auralynq (uid 10001); all services get security_opt: ["no-new-privileges"], restart: unless-stopped, and healthchecks. Named volumes (auralynq-data, auralynq-reports) initialize with the image's non-root owner so writes succeed without host-ownership clashes.

Rationale. Satisfies "expose only UI+API", "enable API auth", and "never expose secrets" simultaneously; the proxy also removes CORS as an attack surface (browser calls are same-origin). Restart + healthchecks give crash recovery. TLS / public reverse proxy is intentionally deferred to a separate step.

Alternatives rejected. Baking the key into the browser bundle (leaks the secret); breaking the UI when auth is on (fails the "UI reachable" goal); binding internal services to 127.0.0.1 only (the rootless API can't reach host loopback β€” no working inter-container DNS on this Podman 3.4/CNI host); fixing DNS (dnsname/aardvark not available here).


ADR-0013 β€” TLS reverse proxy (Caddy), single public HTTPS port

Context. The hardened stack (ADR-0012) exposed plaintext HTTP on the API/UI ports. Production remote access needs TLS, ideally a single public entrypoint.

Decision. Add a Caddy reverse-proxy service as the ONLY container published on 0.0.0.0 (default :8443). It terminates TLS and forwards to the web container (auralynq-web:3000), which serves the UI and proxies /api/* to the API. web, api, qdrant, phoenix only bind to ${AURALYNQ_BIND_INTERNAL:-127.0.0.1} (loopback) plus the shared container network. TLS uses a self-signed cert baked into the Caddy image (containers/caddy.Dockerfile) with the server IP + localhost as SANs, served via an explicit tls /certs/site.crt /certs/site.key directive β€” reliable for an IP / no-domain host (Caddy's on-demand internal-CA issuance is unreliable for bare-IP sites). HTTP/3 is disabled (rootless can't size the QUIC UDP buffer). flush_interval -1 keeps SSE streaming + WebSocket voice unbuffered.

Rationale. One public TLS port; the browser only ever talks to Caddy; the API key never leaves the server (web /api proxy injects it). For a real domain, set AURALYNQ_SITE_ADDRESS=https://your.domain + AURALYNQ_TLS=internal (or ACME).

Alternatives rejected. tls internal for a bare-IP site (handshake fails β€” no leaf cert for IP SNI); HTTP/3 (broken UDP buffer on rootless); terminating TLS in the app (re-implements a proxy, no cert automation).


ADR-0014 β€” No-sudo fix for rootless container DNS (CNI 0.4.0 + dnsname)

Context. This host runs rootless Podman 3.4 with CNI. Two defects broke inter-container networking: (1) podman generates network conflists with cniVersion: 1.0.0, but the installed firewall CNI plugin only supports ≀0.4.0, so it fails validation and collapses the plugin chain; (2) the default podman network ships without the dnsname plugin, so there is no container DNS at all. Earlier ADR-0012 worked around this with brittle bridge-IP wiring and shared network namespaces. A proper fix via podman 4 / netavark needs root β€” but the whole point of rootless Podman is to avoid sudo.

Decision. Fix it without sudo by rewriting the user-owned CNI conflists in ~/.config/cni/net.d/: pin cniVersion to 0.4.0 (which firewall, bridge and dnsname all support) and append the dnsname plugin if absent. scripts/stack_up.sh does this idempotently before up. With DNS restored, all services share podman-compose's default network and address peers by container_name (auralynq-qdrant / auralynq-api / auralynq-web) β€” eliminating the bridge-IP guessing and shared-netns hacks. dnsname requires the dnsmasq binary (present) and the plugin binary at /usr/lib/cni/dnsname (present; the rootless plugin dir is set in ~/.config/containers/containers.conf).

Rationale. Pure user-space, persists across down/up, and restores the clean service-name networking Podman is supposed to provide. Supersedes the bridge-IP workaround noted in ADR-0012.

Alternatives rejected. podman 4 + netavark (needs root); external networks in compose (podman-compose 1.5.0 ignores them β€” attaches to the default net anyway); shared network namespace for every service (couples lifecycles, can't publish per-container ports); service-name aliases (dnsname resolves container_name, not the compose service name, on this version).


ADR-0015 β€” MCP as a first-class microservice (stdio + streamable-HTTP)

Context. The MCP server exposes Auralynq's 7 tools but was stdio-only β€” usable by a local client that spawns the process, not by remote clients. The product goal is to "serve production-ready around the world" as composable microservices.

Decision. Keep the transport-agnostic tool functions (auralynq/mcp_server/tools.py) as the single source of truth, and make the server transport-selectable via --transport / AURALYNQ_MCP_TRANSPORT: stdio (default, local), streamable-http (remote microservice on AURALYNQ_MCP_HOST:PORT, default :8765), or sse (legacy). A dedicated auralynq-mcp container in compose.yml runs the HTTP transport (internal-only; front with the Caddy TLS proxy for public exposure). An integration test drives the real stdio protocol end-to-end (client β†’ server β†’ tool) and auto-skips when the optional SDK is absent.

Rationale. One tool implementation, many entry points (CLI, FastAPI, local MCP, remote MCP). HTTP transport lets agents/IDEs anywhere call the same grounded RAG + PathRAG + voice tools β€” the microservices foundation the roadmap needs β€” without duplicating logic.

Alternatives rejected. A bespoke REST shim around the tools (re-implements MCP, loses client compatibility); HTTP-only (breaks the zero-config local Claude Desktop flow); embedding MCP in the FastAPI app (couples two servers with different lifecycles and auth models).


ADR-0016 β€” Bearer-token auth for the MCP HTTP transports

Context. ADR-0015 made MCP remotely callable over HTTP. An open HTTP MCP endpoint exposes the full ingest/search/agent toolset to anyone who can reach it β€” unacceptable for public deployment.

Decision. Add an ASGI MCPAuthMiddleware wrapped around FastMCP's streamable_http_app() / sse_app() (served via uvicorn in _serve_http). It requires Authorization: Bearer <key> when a key is configured, returning a structured 401 otherwise; comparison is constant-time (hmac.compare_digest). Key resolution: AURALYNQ_MCP_API_KEY (dedicated) wins, else AURALYNQ_SERVE__API_KEY (reuse the HTTP API key). Empty β‡’ open. The stdio transport is never gated (it is local β€” the client spawns the process), so the zero-config Claude Desktop flow is unaffected.

Rationale. Mirrors the HTTP API's auth model (ADR-0011) for consistency; one env var turns the remote MCP microservice from open to authenticated with no code change; constant-time compare avoids timing leaks. Combined with the Caddy TLS proxy (ADR-0013) the token travels encrypted.

Alternatives rejected. OAuth/JWT (over-engineered for a single-tenant tool; can layer behind the same middleware later); gating stdio (breaks local clients); relying only on network isolation (defense-in-depth β€” auth + internal binding + TLS are complementary).


ADR-0017 β€” Versioned container images + GHCR registry

Context. "Serve production-ready around the world" needs deployable, pinnable, traceable images β€” not a single mutable :latest. The repo lives on GitHub.

Decision. Publish three OCI-labelled images (auralynq-api, auralynq-web, auralynq-caddy) to GitHub Container Registry (ghcr.io/<owner>/…). The version is the single source auralynq.__version__; every build carries four tags: X.Y.Z (immutable release), X.Y (latest patch of a minor), <git-sha> (exact provenance), and latest (convenience). Tooling: scripts/image_env.sh (shared config), scripts/build_images.sh, scripts/push_images.sh, and make images|push|version. The authoritative publish path is CI: .github/workflows/release.yml builds + pushes on a v* git tag using the workflow's GITHUB_TOKEN (packages: write) β€” no manual login. compose.yml references ${AURALYNQ_IMAGE_PREFIX:-auralynq-}<svc>:${AURALYNQ_IMAGE_TAG:-X.Y.Z} so it runs locally-built images by default and a pinned GHCR version when set.

Rationale. Immutable + minor + sha + latest covers reproducible deploys, easy patching, exact provenance, and convenience. GHCR is zero-config from GitHub Actions. The single-source version + one tag scheme prevents drift; OCI labels make images self-describing (source, revision, license).

Alternatives rejected. Bare :latest only (no rollback/pinning); Docker Hub (extra account/secret vs. GHCR's built-in token); a separate VERSION file (drifts from the package version); baking secrets into images (they stay in env/.env).


ADR-0018 β€” Microservice split: one image, role entrypoints, k8s for scaling

Context. The roadmap calls for "independently scalable microservices, served worldwide". The services already run as separate containers (api/mcp/worker/web + qdrant/phoenix/caddy), but podman-compose 1.5 on this host can't do per-service profiles or isolated subset deploys (verified: up <svc> pulls the whole depends_on graph; COMPOSE_PROFILES is ignored), and a single-host compose file isn't where independent autoscaling happens anyway.

Decision. Split by process/role over one shared image, not by duplicated code or repos: api, mcp, and worker are the same auralynq-api image with different entrypoints (uvicorn …app / python -m …mcp_server.server / python -m …serving.worker); web is its own image. Ship Kubernetes manifests (deploy/k8s, kustomize) as the production scaling path: each service is its own Deployment+Service, api/mcp/web autoscale via HPA against a single Qdrant StatefulSet, the web UI is the only public surface (Ingress+TLS), and config/secrets are a ConfigMap+Secret. Compose remains the local/single-host mode. Topology + scaling rules are documented in docs/SERVICES.md.

Rationale. One image = one build/test/provenance surface; roles diverge only by args + env. k8s is where "independently scalable" is real (per-service replicas, HPA, rolling updates), and the GHCR images (ADR-0017) make kubectl apply -k deployable anywhere. Stateless-against-Qdrant api/mcp scale freely; the one stateful tier (Qdrant) is isolated. Honest about the boundary: in-memory vector backend is NOT multi-replica-safe β€” k8s config defaults to the Qdrant backend.

Alternatives rejected. Separate repos per service (duplicated core, version skew, against the "one repo, keep updating" goal); per-service images for api/mcp/worker (3Γ— build/scan for identical code); compose profiles (broken on podman-compose 1.5); baking scaling into compose (single-host; no autoscaling).


ADR-0019 β€” Optional Langfuse trace export (best-effort, never blocking)

Context. The agent already builds an in-process Trace (per-node spans) and mirrors it to OpenTelemetry/Phoenix. Production observability for a service "served worldwide" benefits from a hosted trace/eval backend (Langfuse), but it must never become a hard dependency or a failure point on the answer path.

Decision. Add auralynq/telemetry/langfuse_export.py: a best-effort exporter that pushes a finished Trace to Langfuse as a trace (question input, answer/route/metadata output) with one nested span per agent node. It is wired at the end of answer_question and stream_answer_question via a _export_langfuse helper that swallows all errors. Activation requires BOTH LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY AND the importable langfuse SDK (in the telemetry extra); otherwise it is a silent no-op. Host is configurable for self-hosted Langfuse; /health reports the active tracing backend.

Rationale. Same provider-abstraction + offline-safe pattern as every other integration (ADR-0003/0004): one config flag turns it on, missing SDK/keys/network degrade to the local tracer, and observability can never break or slow an answer. Complements (does not replace) the in-process trace and Phoenix mirror.

Alternatives rejected. Hard-requiring langfuse (breaks $0 default, heavy dep); exporting inline inside each node (couples the agent to a vendor, multiplies failure surface); replacing the in-process tracer (loses the UI trace panel + the always-on local record).

ADR-0020 β€” Client-side API failover at the web proxy (local-primary, remote-backup)

Context. Auralynq can run as a full stack on a laptop with all real provider keys, while a remote server (reached at https://<server>:8443) stays available as a backup. We want the laptop UI to keep answering when the laptop's own API container is down or wedged β€” without the user switching URLs or losing the same-origin/secret-injection model (ADR-0012).

Decision. The Next.js /api/[...path] proxy gained an ordered-upstream failover policy (web/lib/failover.ts, pure + unit-tested): try the local/primary backend (AURALYNQ_API_INTERNAL) first; if it is unreachable (connection error or time-to-first-byte over AURALYNQ_API_PRIMARY_TIMEOUT_MS, default 12s) or returns a gateway-class status (502/503/504), replay the same request against AURALYNQ_API_FALLBACK (e.g. https://<server>:8443/api). When a fallback is configured the request body is buffered so it can be replayed; with no fallback the proxy streams the body untouched (unchanged behavior). The self-signed fallback cert is trusted via an undici Agent (connect.rejectUnauthorized:false) passed as the global-fetch dispatcher, enabled only when AURALYNQ_API_FALLBACK_INSECURE_TLS=1. The chosen upstream is reported in the x-auralynq-upstream: primary|fallback|none response header.

Rationale. Failover belongs at the proxy: it already terminates the same-origin contract and holds the bearer token, so streaming (SSE) and auth keep working transparently. Only gateway-class statuses trigger failover β€” a 4xx/500 means the backend is up and answering, so failing over would mask real bugs. Bounding only time-to-first-byte (not the whole stream) lets long LLM responses flow while still escaping a hung backend. Empty AURALYNQ_API_FALLBACK is a no-op, so server deployments are unaffected.

Alternatives rejected. DNS/round-robin or an external LB (heavier, loses the secret-injection proxy); client-side (browser) failover (would expose the backup URL + key to the browser, breaking ADR-0012); failing over on any 5xx (masks application errors); NODE_TLS_REJECT_UNAUTHORIZED=0 (disables TLS verification process-wide instead of only for the one self-signed fallback).


ADR-0021 β€” PPR-augmented PathRAG path scoring

Context. The original PathRAG path scorer uses a resource-decay flow model: a budget is distributed from seed nodes with per-hop decay; a path's score is the bottleneck (minimum) edge flow. This correctly penalises long, low-reliability chains but has a structural bias toward short paths β€” a one-hop path from a seed always accrues the full initial resource regardless of how central its terminal node is in the broader graph.

Decision. Augment the flow score with Personalised PageRank (PPR) terminal-node authority. _assign_ppr() builds a collapsed DiGraph (no multi-edges, weights = edge_weight Γ— (0.1 + reliability)), runs nx.pagerank() with a personalisation vector concentrated on the seed entities (alpha = 0.15 teleport = 85% damping), then normalises scores to [0, 1]. The blended path score is 0.4 Γ— flow_component + 0.6 Γ— ppr_terminal. Flow provides edge-level robustness; PPR provides convergent multi-hop authority.

Rationale. PPR is the retrieval mechanism in HippoRAG (NeurIPS'24) and HippoRAG2, where it outperforms flow-only scoring on multi-hop benchmarks by 5–20 pp. Keeping the flow term (40 %) preserves the edge-reliability signal for short, high-precision paths while the PPR term (60 %) rewards nodes that aggregate evidence from many query-relevant directions. The _apply_ppr() call is a separate pass so the existing flow-based pruning (_prune) removes candidate paths first β€” PPR is only computed over the retained set.

Alternatives rejected. Pure PPR without flow (loses per-edge reliability weighting); learning-to-rank path scoring (requires labelled training data, breaks $0 constraint); raising max_hops to compensate for flow's short-path bias (exponential expansion cost).


ADR-0022 β€” Dual-signal evidence sufficiency (FAIR-RAG SEA)

Context. node_critic used token-level overlap to decide whether to rewrite the query: coverage = 1 - |gap_terms| / |q_terms|. This correctly identifies missing vocabulary but produces false-positive rewrites when the query and documents use synonymous or paraphrased vocabulary β€” the retrieval is semantically sufficient even though the surface coverage is low. This caused unnecessary rewrite iterations and increased latency on paraphrase-heavy corpora (technical domains, multilingual documents).

Decision. Add a second gate: _semantic_coverage() computes the cosine similarity between the query dense embedding and the mean of all retrieved-context dense embeddings, mapped from [βˆ’1, 1] to [0, 1]. A rewrite is only triggered when both coverage < 0.6 and semantic_coverage < 0.5. A semantic_coverage β‰₯ 0.5 reading means the retrieved passages are on-topic at the embedding level even if their surface tokens differ, so rewriting would likely recall the same passages with worse diversity.

Rationale. Mirrors the Structured Evidence Assessment (SEA) module in FAIR-RAG (arXiv:2510.22344, Oct 2025), which found that combining lexical and semantic sufficiency signals reduces spurious rewrites by ~30 % on paraphrase test sets. The embedder is already loaded in AgentDeps.hybrid.embedder β€” no extra model weight. Falls back to 0.0 (always allows rewrite) for the offline hashing embedder, which does not produce meaningful cosine similarities.

Alternatives rejected. LLM-as-judge sufficiency check (too slow, too expensive for the $0 constraint); BM25 IDF overlap (still purely lexical, same failure mode); ROUGE/BLEU (designed for generation, not retrieval sufficiency).


ADR-0023 β€” Calibrated four-signal confidence scoring

Context. The heuristic confidence formula 0.4 * context_count_fraction + 0.3 * has_citations + 0.3 * token_coverage_fraction conflates three different phenomena: how many passages were retrieved (quantity), whether the LLM cited any of them (utilisation), and whether the question's vocabulary was covered (lexical breadth). A high score on all three is necessary but not sufficient β€” the retrieved passages could be marginally relevant (low score) yet plentiful enough to drive a high confidence score.

Decision. Replace with four orthogonal signals:

  • score_quality = clip(mean_retrieval_score / 0.7, 0, 1). Reference 0.7 is the empirical centre of bge-m3 cross-encoder scores for on-topic passages (0.65–0.80 range). Scores below 0.3 indicate marginal relevance.
  • citation_coverage = len(cited_contexts) / len(all_contexts). Measures LLM utilisation: a low ratio means the LLM ignored most retrieved evidence, suggesting a poor fit.
  • semantic_coverage = cosine(query_emb, mean_ctx_embs) from node_critic. Grounding quality at the embedding level.
  • token_coverage = 1 βˆ’ gap_fraction. Lexical breadth. Blended: conf = 0.30 Γ— sq + 0.30 Γ— cc + 0.25 Γ— sc + 0.15 Γ— tc. Weights were chosen so that retrieval quality and citation utilisation dominate (together 60 %), reflecting the Bayesian RAG finding (Frontiers 2026) that retrieval score distributions correlate ρ = 0.94 with precision at the answer level.

Rationale. The new formula exposes why confidence is low via the ConfidenceBar UI component, which renders all four signals. This is a transparency improvement aligned with the UncertaintyRAG literature (arXiv 2025). The weights are empirically motivated but the formula is deterministic and does not require a held-out calibration set.

Alternatives rejected. Monte-Carlo dropout or logit-based uncertainty (requires white-box LLM access, breaks the provider-agnostic design); Platt scaling (requires labelled confidence data); the existing heuristic (masked the retrieval quality signal).