|
|
@@ -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.
|