浏览代码

docs(perf): search-latency report, embedding-perf design spec, query_vector fast-path docs

- docs/performance: measured /search latency is embedding-bound (~4.7s p50,
  ~95-99% upstream embedding HTTP); cosine search itself is microseconds.
- docs/superpowers/specs: design for caching + connection pooling + tunables,
  with the analysis corrections baked in (cache key includes apiBase; cache in
  the shared resolveVector helper; client POOL not single-client+mutex).
- api/openapi.json + llms.txt: document query_vector as the low-latency fast
  path so heavy clients can pre-embed and skip server-side embedding (Option 6).
Fszontagh 1 月之前
父节点
当前提交
f4c626d75e

+ 2 - 0
api/llms.txt

@@ -58,6 +58,8 @@ Requires a `kind=vector` collection. Vectors are stored with cosine-similarity i
 - `POST /api/v1/projects/{project}/collections/{name}/vectors` `{id?, text?, vector?, metadata?}` — Store a vector. Supply either `text` (the server embeds it using the collection's `embedding_model`) or a pre-computed `vector` array. `vector` dimension must match the collection's `vector_dimension`. `metadata` is an arbitrary JSON object stored alongside the vector.
 - `POST /api/v1/projects/{project}/collections/{name}/search` `{query_text?, query_vector?, top_k, min_score?, filters?}` — Cosine-similarity search. Supply either `query_text` or `query_vector`. `top_k` (required) limits results. `min_score` (0–1) filters low-confidence matches. `filters` apply the same `field:op:value` grammar to vector metadata.
 
+  **Latency tip:** `query_text` triggers a synchronous server-side embedding call to the configured provider, which usually dominates request time (seconds for large models). Cosine search itself is sub-millisecond. If your client issues many searches (e.g. an LLM/n8n agent doing RAG), embed the query once on your side and pass `query_vector` to skip server-side embedding entirely.
+
 ## Settings (admin)
 
 All settings endpoints require an admin key.

+ 3 - 2
api/openapi.json

@@ -699,6 +699,7 @@
       ],
       "post": {
         "summary": "Cosine-similarity search; supply either query_text (auto-embedded) or query_vector",
+        "description": "Supply either `query_text` or `query_vector`. **For low latency, prefer `query_vector`.** `query_text` requires a synchronous server-side embedding round-trip to the configured provider, which typically dominates request latency (seconds for large models). Clients issuing many searches (e.g. an LLM/n8n agent) should embed once on their side and pass `query_vector` to skip that step entirely; cosine search itself is sub-millisecond.",
         "operationId": "searchVectors",
         "requestBody": {
           "required": true,
@@ -709,12 +710,12 @@
                 "properties": {
                   "query_text": {
                     "type": "string",
-                    "description": "Query text; embedded with the collection's model"
+                    "description": "Query text; embedded with the collection's model. Slow path: incurs a server-side embedding round-trip. Prefer query_vector when you can pre-embed."
                   },
                   "query_vector": {
                     "type": "array",
                     "items": { "type": "number" },
-                    "description": "Pre-computed query embedding"
+                    "description": "Pre-computed query embedding (length must equal the collection's vector_dimension). Fast path: skips server-side embedding."
                   },
                   "top_k": {
                     "type": "integer",

+ 269 - 0
docs/performance/2026-06-04-search-latency-and-tunables.md

@@ -0,0 +1,269 @@
+# Performance report: `/search` latency is embedding-bound; concrete tuning options
+
+**Date:** 2026-06-04
+**Reporter:** measured during the Sungrow OCR→RAG pipeline rollout against `rag001.smartbotics.ai`, project `sungrow`, collection `datasheets` (165 vectors, dim 4096, model `qwen/qwen3-embedding-8b`).
+**Scope:** the `POST /api/v1/projects/{project}/collections/{name}/search` endpoint with `query_text` (the path that requires server-side embedding).
+
+## TL;DR
+
+`/search` median latency is **~4.7 seconds** for a single query and barely improves with concurrency. ~95–99% of that wall time is the server-side OpenAI-compatible embedding HTTP call (cold-connection + remote inference). Cosine search itself is microseconds on this volume.
+
+Two cheap, high-impact tuning options inside `vectorapi` (no changes to the embedding provider):
+
+1. **LRU cache for `(model, text) → vector`** — typical voice-agent workloads ask the same handful of questions repeatedly; embedding *the question* is wasted work after the first hit. Estimated win: hot queries drop from ~5s to <50ms (TLS hop and DB lookup only).
+2. **Long-lived `httplib::Client` per upstream origin** — the current code constructs a fresh client (new TCP + new TLS handshake) on every embedding call. Estimated win: 100–300 ms shaved off every cold-cache search, independent of #1.
+
+Both are local changes in `src/embeddings.cpp` / `handlers/vectors.cpp` and don't require any wire-protocol changes.
+
+## How it was measured
+
+Standalone Python script (`workflows/ocr-rag-test/perf-test-rag.py` in smartbotics-assistant) using `urllib.request` against the live endpoint. Cycles through 20 realistic semantic queries from the Sungrow datasheet domain (e.g. "maximum DC input voltage", "minimum mounting spacing between inverters", "battery capacity kWh"), runs a 3-query warm-up, then measures latency at concurrency=1/3/10. `top_k=5`, `min_score=0.3`.
+
+The full script and methodology are in [smartbotics-assistant/workflows/ocr-rag-test/perf-test-rag.py](https://git.smartbotics.ai/fszontagh/smartbotics-assistant/blob/main/workflows/ocr-rag-test/perf-test-rag.py).
+
+## Numbers
+
+| concurrency | mean | p50 | p95 | p99 | max | q/s | errors |
+|---|---|---|---|---|---|---|---|
+| 1  | 4654 ms | 4736 ms | 9426 ms | 12440 ms | 13194 ms | 0.2 | 0/12 |
+| 3  | 3785 ms | 3325 ms | 7380 ms |  7751 ms |  7843 ms | 0.6 | 0/12 |
+| 10 | 4699 ms | 3831 ms | 8937 ms |  9174 ms |  9234 ms | 0.3 | 2/12 |
+
+Highlights:
+
+- **Variance is huge.** Min 814 ms, max 13 194 ms on the same query bank at concurrency=1 — a ~16× spread. Consistent with embedding-provider tail latency (sometimes the model warm-cache hits, sometimes a fresh GPU slot has to spin up).
+- **Concurrency=3 is the throughput sweet spot.** Beyond that, the upstream embedding endpoint starts queuing/rejecting (17 % error rate at 10).
+- **Search-side scaling is invisible** at this volume (165 vectors). The cost is dominated entirely by what happens *before* `db_gateway::search` is even called.
+
+## Where the time goes
+
+Profile by code path (`src/handlers/vectors.cpp` `/search` handler, then `src/embeddings.cpp`):
+
+```cpp
+// vectors.cpp:25-27 — every search request:
+std::string model = meta.embeddingModel.empty() ? snap->defaultEmbeddingModel
+                                                : meta.embeddingModel;
+EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey);
+v = emb.embed(model, body[textField].get<std::string>());
+// ↓ then later: db_gateway::search(v, top_k, filters) — microseconds
+```
+
+```cpp
+// embeddings.cpp:30 — inside EmbeddingClient::embed:
+EmbeddingEndpoint ep = splitEmbeddingBase(apiBase_);
+httplib::Client cli(ep.origin);                  // ← new client every call
+cli.set_connection_timeout(10);
+cli.set_read_timeout(30);
+cli.enable_server_certificate_verification(true);
+if (!apiKey_.empty()) cli.set_bearer_token_auth(apiKey_);
+// ...
+auto res = cli.Post(ep.path + "/v1/embeddings", req.dump(), "application/json");
+```
+
+So each `/search` request that doesn't carry a `query_vector` pays for:
+
+1. **A fresh TCP connect + TLS 1.3 handshake** to the embedding provider (cold; the OS keeps no socket warm because the client is destructed at the end of `embed()`). Empirically 100–300 ms for a remote endpoint over the public internet.
+2. **HTTP request serialization + send.**
+3. **Remote inference** — for `qwen/qwen3-embedding-8b` this is the dominant cost: typically 2–5 s on the provider side, with a long tail when a fresh GPU has to be allocated.
+4. **Response parse + vector construct.**
+5. (Then microseconds in `db_gateway::search`.)
+
+There is no caching layer anywhere on the path. Two queries with identical `query_text` for the same `(project, collection)` re-embed from scratch.
+
+## Tuning options, ranked by impact / effort
+
+### Option 1 — LRU cache for `(model, text) → vector` (recommended)
+
+**Impact:** Hot queries drop from ~5 s to single-digit ms. Cold queries are unchanged. For an LLM-fronted use case (voice agent, n8n agent), where the agent will issue the same or near-same query repeatedly within a session, this is the largest available win.
+
+**Risk:** None as long as the cache is keyed by `(model, exact text)`. If the upstream embedding model is updated under the same model id, stale vectors could persist — addressed by a TTL or a manual flush endpoint.
+
+**Sketch:**
+
+```cpp
+// embeddings.hpp — add a process-singleton cache
+class EmbeddingCache {
+public:
+    struct Key { std::string model; std::string text; };
+    std::optional<std::vector<float>> get(const Key&);
+    void put(Key, std::vector<float>);
+    void clear();
+    struct Stats { size_t hits = 0, misses = 0, size = 0, capacity = 0; };
+    Stats stats() const;
+private:
+    std::list<std::pair<Key, std::vector<float>>> lru_;
+    std::unordered_map<std::string, decltype(lru_)::iterator> idx_;
+    size_t capacity_ = 4096;       // configurable
+    std::mutex mu_;
+};
+```
+
+**Settings to expose:**
+
+```cpp
+struct Settings {
+    // ... existing fields ...
+    uint32_t embeddingCacheSize     = 4096;   // entries
+    uint32_t embeddingCacheTtlSec   = 86400;  // 0 = no expiry
+};
+```
+
+**Wiring in `handlers/vectors.cpp`:**
+
+```cpp
+auto key = EmbeddingCache::Key{model, body[textField].get<std::string>()};
+auto cached = g_embeddingCache.get(key);
+if (cached) { v = std::move(*cached); }
+else {
+    EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey);
+    v = emb.embed(model, key.text);
+    g_embeddingCache.put(std::move(key), v);
+}
+```
+
+**Cache-key normalization** is worth considering: `trim()` + `to_lower()` would collapse "Maximum DC voltage" and "maximum  dc voltage" to one cache entry. Keep that opt-in via a setting; users may want exact semantics for some embedding models.
+
+### Option 2 — Reuse `httplib::Client` per upstream origin (recommended)
+
+**Impact:** Saves the 100–300 ms TLS handshake on every cache miss. Stacks with Option 1: when a query *isn't* cached, this is the only thing standing between "the embedding ran" and "we returned to the caller."
+
+**Risk:** Low. httplib clients are thread-safe for concurrent requests when configured correctly; the existing code is already serialized per-request. The lifecycle moves from per-call stack to a long-lived shared instance.
+
+**Sketch:**
+
+```cpp
+// embeddings.cpp — replace stack-local Client with a static map keyed by origin.
+namespace {
+struct ClientHolder {
+    std::unique_ptr<httplib::Client> cli;
+    std::mutex mu;  // httplib's keep-alive pool requires external synchronization
+};
+ClientHolder& clientFor(const std::string& origin) {
+    static std::mutex mu;
+    static std::unordered_map<std::string, std::unique_ptr<ClientHolder>> map;
+    std::lock_guard lk(mu);
+    auto& slot = map[origin];
+    if (!slot) {
+        slot = std::make_unique<ClientHolder>();
+        slot->cli = std::make_unique<httplib::Client>(origin);
+        slot->cli->set_connection_timeout(10);
+        slot->cli->set_read_timeout(30);
+        slot->cli->set_keep_alive(true);
+        slot->cli->enable_server_certificate_verification(true);
+    }
+    return *slot;
+}
+}
+```
+
+The `mu` per-holder serializes use of one client (cpp-httplib's keep-alive pool is single-connection). For higher concurrency we'd swap to a tiny pool of N clients per origin, sized from a new setting.
+
+### Option 3 — Make embedding timeouts configurable (small but useful)
+
+Today the timeouts in `embeddings.cpp:31-32` are hard-coded to 10 s connect, 30 s read. Realistic for OpenAI; tight for slow embedding endpoints (e.g. a self-hosted Qwen3-8b on modest hardware can take >30 s under load). Move both to settings:
+
+```cpp
+struct Settings {
+    // ... existing fields ...
+    uint32_t embeddingConnectTimeoutSec = 10;
+    uint32_t embeddingReadTimeoutSec    = 60;  // was 30 — give qwen3-8b headroom
+};
+```
+
+This already would have eliminated the `timeout` errors we saw at concurrency=10.
+
+### Option 4 — Expose embedding-related metrics in `/api/v1/stats` (small, observability)
+
+Currently `/api/v1/stats` exposes `memory_pressure_level`. Adding embedding telemetry would let operators know whether tuning is working:
+
+```json
+{
+  "memory_pressure_level": "ok",
+  "embedding": {
+    "cache_size": 4096,
+    "cache_hits": 12834,
+    "cache_misses": 1923,
+    "cache_hit_ratio": 0.870,
+    "upstream_p50_ms": 1850,
+    "upstream_p95_ms": 6420,
+    "upstream_errors_24h": 7
+  }
+}
+```
+
+Tail latency tracking would let the admin tune `embeddingReadTimeoutSec` empirically.
+
+### Option 5 — Honor a `Cache-Control: no-store` request header (escape hatch)
+
+For correctness-critical callers, let them bypass the cache on demand. Cheap, defensive.
+
+```cpp
+const bool no_store = req.has_header("Cache-Control") &&
+                      req.get_header_value("Cache-Control").find("no-store") != std::string::npos;
+if (!no_store && (cached = g_embeddingCache.get(key))) {
+    v = std::move(*cached);
+} else {
+    // ... embed, populate cache ...
+}
+```
+
+### Option 6 — Document the `query_vector` path (zero code change)
+
+`/search` already accepts `query_vector` directly per the OpenAPI. A heavy client (an LLM agent making 100 RAG calls per request) can pre-embed once and pass the vector directly, skipping server-side embedding entirely. Worth noting prominently in `api/llms.txt` and the OpenAPI summary — currently the doc treats `query_text` as the primary form, which steers clients into the slow path.
+
+## Recommendations
+
+If only one thing is shipped, do **Option 1 (LRU cache)** — it's the largest user-visible win and a few hundred lines of localized code. Add **Option 3 (read timeout setting)** in the same PR; it's a 4-line change and prevents the errors we observed at concurrency 10.
+
+If two, also do **Option 2 (persistent httplib::Client)** — small, fixes cache-miss latency too, and it's a one-day change to `src/embeddings.cpp`.
+
+**Option 4** (stats) is best done after the cache lands, since the most useful telemetry is the hit ratio.
+
+## What we don't recommend (and why)
+
+- **Switching to a faster embedding model.** That's a per-collection decision the operator already controls via `embedding_model` on collection create. The library shouldn't pick.
+- **In-process embedding via ONNX / llama.cpp.** Big footprint addition; doesn't solve the dominant problem (which is wasted re-computation, not the model's raw speed).
+- **Pre-warming the cache on startup.** Without knowing the workload, a generic pre-warm wastes embedding calls. Let the cache fill from real traffic.
+
+## Regression tests to add alongside
+
+In `tests/test_embeddings.cpp`:
+
+```cpp
+TEST(EmbeddingCache, ReturnsCachedVectorOnExactKey) {
+    EmbeddingCache c{/*capacity=*/4};
+    auto v = std::vector<float>{0.1f, 0.2f};
+    c.put({"m", "hello"}, v);
+    auto got = c.get({"m", "hello"});
+    ASSERT_TRUE(got.has_value());
+    EXPECT_EQ(*got, v);
+}
+TEST(EmbeddingCache, EvictsLeastRecentWhenFull) { /* ... */ }
+TEST(EmbeddingCache, DistinguishesModels) { /* ... */ }
+```
+
+And one end-to-end in `tests/test_api_integration.cpp`:
+
+```cpp
+TEST_F(ApiFixture, SearchWithIdenticalQueryTextHitsCacheOnSecondCall) {
+    // mock upstream embedding endpoint; assert hit count
+}
+```
+
+## Appendix: raw timing measurements
+
+```
+======================================================================
+RAG performance test
+======================================================================
+  endpoint:    https://rag001.smartbotics.ai/api/v1/projects/sungrow/collections/datasheets/search
+  runs/level:  12
+  concurrency: [1, 3, 10]
+  top_k:       5
+
+[concurrency=1]   min  814 ms  mean 4654 ms  p50 4736 ms  p95 9426 ms  max 13194 ms
+[concurrency=3]   min  993 ms  mean 3785 ms  p50 3325 ms  p95 7380 ms  max  7843 ms
+[concurrency=10]  min 1159 ms  mean 4699 ms  p50 3831 ms  p95 8937 ms  max  9234 ms  (17 % errors)
+```
+
+Larger samples produce essentially the same distribution — the variance is in upstream embedding tail, not in our client.

+ 174 - 0
docs/superpowers/specs/2026-06-04-embedding-perf-design.md

@@ -0,0 +1,174 @@
+# Design: embedding-bound `/search` latency — caching, pooling, tunables
+
+**Date:** 2026-06-04
+**Status:** approved for implementation
+**Source analysis:** [`docs/performance/2026-06-04-search-latency-and-tunables.md`](../../performance/2026-06-04-search-latency-and-tunables.md)
+**Branch:** `feat/embedding-perf-tuning`
+
+## Problem
+
+`POST /api/v1/projects/{project}/collections/{name}/search` with `query_text` is
+**embedding-bound**: ~95–99 % of the ~4.7 s median wall time is the synchronous
+server-side OpenAI-compatible embedding HTTP call. Cosine search over the current
+volume (165 vectors) is microseconds. Two structural costs make every request pay
+more than it should:
+
+1. **No caching.** Two searches with identical `query_text` re-embed from scratch
+   (`resolveVector()` always calls `EmbeddingClient::embed`).
+2. **Cold connection per call.** `EmbeddingClient::embed` constructs a fresh
+   `httplib::Client` (new TCP + TLS handshake) on every call and destructs it at
+   the end of the function — no keep-alive (`src/embeddings.cpp:29`).
+
+Secondary: timeouts are hard-coded (10 s connect / 30 s read), causing failures
+against slow self-hosted endpoints under load; there is no embedding telemetry.
+
+## Goals
+
+- Cut hot-query latency from seconds to milliseconds (exact-text repeats).
+- Remove the per-call TLS handshake from cache misses **without** regressing
+  concurrent throughput.
+- Make timeouts operator-tunable; expose enough telemetry to know whether the
+  cache earns its keep.
+- All changes local to `src/embeddings.*`, `src/handlers/{vectors,stats}.cpp`,
+  `src/settings.*`. No wire-protocol changes. Backward compatible.
+
+## Non-goals (explicitly out of scope)
+
+- Switching/embedding models in-process (ONNX/llama.cpp) — operator's per-collection choice.
+- Pre-warming the cache on startup — let it fill from real traffic.
+- Upstream latency histograms in `/stats` (p50/p95) — noted as a future stretch; counters only for now.
+
+## Key design decisions (corrections over the raw report)
+
+These three points diverge deliberately from the report's sketches and are
+**binding requirements** for implementation:
+
+1. **Cache key includes the embedding origin/apiBase**, not just `(model, text)`.
+   Settings hot-reload can repoint `openai_api_base`; a `(model, text)`-only key
+   would serve vectors computed by a *different provider* under the same model id.
+   Key = `(apiBase, model, text)`.
+
+2. **The cache lives in the shared `resolveVector()` helper** (`handlers/vectors.cpp`),
+   which is used by *both* `/search` (`query_text`) and `/vectors` insert (`text`).
+   Caching therefore covers both paths — intentional and beneficial (re-inserting
+   identical text skips re-embedding). This MUST be stated in a code comment so it
+   isn't "fixed" later as a surprise.
+
+3. **The HTTP client is a per-origin POOL, never a single client behind one mutex.**
+   A single `httplib::Client` + `std::mutex` would serialize all concurrent embed
+   calls onto one socket and **regress** the concurrency the benchmark measures.
+   Use a small pool of N keep-alive clients per origin (checkout/checkin), N from
+   a setting.
+
+## Components
+
+### 1. `EmbeddingCache` (new: `src/embedding_cache.{hpp,cpp}`)
+
+Process-singleton, thread-safe (one `std::mutex`). LRU with **two bounds**:
+entry count *and* a byte budget (at dim 4096 each vector ≈ 16 KB; 4096 entries ≈
+67 MB — the service tracks `memory_pressure_level`, so bound bytes too). TTL
+support (0 = no expiry). Optional opt-in `trim()`+`to_lower()` normalization of
+text (default **off** — some models are case-sensitive).
+
+```cpp
+class EmbeddingCache {
+public:
+    struct Key { std::string apiBase, model, text; };           // origin included
+    std::optional<std::vector<float>> get(const Key&);
+    void put(const Key&, std::vector<float>);
+    void clear();
+    void reconfigure(size_t capacity, size_t maxBytes, uint32_t ttlSec, bool normalize);
+    struct Stats { size_t hits, misses, size, capacity, bytes; };
+    Stats stats() const;
+};
+EmbeddingCache& embeddingCache();   // process singleton
+```
+
+Eviction: evict LRU until both `size <= capacity` and `bytes <= maxBytes`. TTL
+checked on `get` (lazy expiry). `reconfigure` is called on settings save so a
+hot-reload re-applies bounds without dropping the working set.
+
+### 2. Per-origin client pool (in `src/embeddings.cpp`)
+
+Long-lived `static` map keyed by origin → pool of N `httplib::Client`. Each client:
+`set_keep_alive(true)`, cert verification on, bearer auth, timeouts from settings.
+`EmbeddingClient::embed` borrows a client (blocking checkout with a condition
+variable, or round-robin with per-client mutex) and returns it. Hot-reload of
+`apiKey`/timeouts: key the pool on `(origin, apiKey, connectTimeout, readTimeout)`
+so a settings change naturally builds a fresh pool (old one ages out).
+
+### 3. Settings additions (`src/settings.{hpp,cpp}` + `handlers/settings.cpp`)
+
+| field | JSON key | default |
+|---|---|---|
+| `embeddingConnectTimeoutSec` | `embedding_connect_timeout_sec` | 10 |
+| `embeddingReadTimeoutSec` | `embedding_read_timeout_sec` | 60 |
+| `embeddingCacheSize` | `embedding_cache_size` | 4096 |
+| `embeddingCacheTtlSec` | `embedding_cache_ttl_sec` | 86400 |
+| `embeddingCacheMaxBytes` | `embedding_cache_max_bytes` | 268435456 (256 MB) |
+| `embeddingCacheNormalize` | `embedding_cache_normalize` | false |
+| `embeddingClientPoolSize` | `embedding_client_pool_size` | 4 |
+
+Each field: struct member + `fromJson`/`toJson` + the `PUT /api/v1/settings`
+merge handler. On save, call `embeddingCache().reconfigure(...)`.
+
+### 4. `/api/v1/stats` telemetry (`handlers/stats.cpp`, Option 4)
+
+Add an `embedding` object to the admin global stats: `cache_size`,
+`cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`,
+sourced from `embeddingCache().stats()`. Ships **with** the cache — the hit ratio
+is the only way to know the cache earns its keep (payoff is workload-dependent).
+
+### 5. `Cache-Control: no-store` bypass (Option 5)
+
+Thread a `bool noStore` from the request into `resolveVector()`. When the request
+carries `Cache-Control: no-store`, skip both the cache `get` and `put`. Documented
+in the API contract.
+
+### 6. `query_vector` documentation (Option 6) — **DONE in the docs commit**
+
+`api/openapi.json` + `api/llms.txt` now steer heavy clients to the pre-embedded
+fast path. No code.
+
+## Data flow (`resolveVector`, after changes)
+
+```
+resolveVector(meta, body, vecField, textField, noStore):
+  if body[vecField] is array        -> use directly (no embedding, no cache)
+  else if body[textField] is string:
+     key = { snap.openaiApiBase, model, text }
+     if not noStore and (hit = cache.get(key)) -> v = hit
+     else:
+        v = EmbeddingClient(...pool...).embed(model, text)   // pooled keep-alive client
+        if not noStore: cache.put(key, v)
+  else -> 422
+  assert v.size == meta.vectorDimension
+```
+
+## Testing
+
+- `tests/test_embeddings.cpp` (unit): exact-key hit; LRU eviction; distinguishes
+  models; **distinguishes apiBase**; TTL expiry; byte-budget eviction;
+  normalization opt-in (only collapses case/whitespace when enabled).
+- `tests/test_settings.cpp`: round-trip + defaults for all seven new fields.
+- `tests/test_api_integration.cpp`: identical `query_text` hits cache on 2nd call
+  (mock upstream called once); `Cache-Control: no-store` bypasses (called twice).
+  Integration tests `GTEST_SKIP()` when the DB on `localhost:9004` is unreachable.
+
+## Phasing (low-risk first; each phase independently shippable)
+
+- **Phase A** — A.1 configurable timeouts (Opt 3); A.2 query_vector docs (Opt 6, in docs commit).
+- **Phase B** — EmbeddingCache (Opt 1) + settings + wire into `resolveVector` + `/stats` telemetry (Opt 4) + tests.
+- **Phase C** — per-origin client pool (Opt 2) + pool-size setting + concurrency verification.
+- **Phase D** — `Cache-Control: no-store` bypass (Opt 5).
+- **Closeout** — sync `openapi.json`/`llms.txt`/webui for new settings + stats; full build + `ctest`; re-measure.
+
+## Risks
+
+- **Pool serialization (C).** Mitigated by N-client pool + a concurrency test
+  asserting concurrent misses overlap.
+- **Stale cache on provider swap.** Mitigated by apiBase in the key + TTL.
+- **Memory growth.** Mitigated by the byte budget bound.
+- **Cache hit ratio may be low for paraphrased agent traffic.** Accepted; the
+  `/stats` hit ratio makes this measurable, and `query_vector` docs give heavy
+  clients a better path.