Skip to content

Commit bef0e0d

Browse files
committed
feat: turnkey Qwen3 OpenAI-compatible embedding server + wire into runtime (slice C)
Makes the semantic embedding quality validated in semantic_bench actually deployable in a running MemTrace service, not just benchmarked. - app/embedding_server.py: a production OpenAI-compatible /v1/embeddings server backed by Qwen3-Embedding-0.6B (FP16, MPS/CUDA/CPU). Honors the OpenAI `dimensions` param via Matryoshka (MRL) truncation + L2 renormalize to the pgvector 256-dim column; lazy singleton model, warmed at startup (lifespan) so requests never cold-start; /health reports model/device/loaded. - scripts/smoke-embedding-server.sh: opt-in end-to-end smoke that, through MemTrace's real OpenAIEmbeddingProvider, verifies (1) the server returns a 256-dim vector and (2) a full retrieve_context wired to the provider recalls the semantically-matched memory for a paraphrase. Skips cleanly if the server is down. - tests/api/test_embedding_server.py: structural tests (health + validation, no model load). docs/deployment.md: the serve + verify + wire recipe. Live-verified: started the server (Qwen3 on MPS), smoke passed both checks (256-dim via the provider; paraphrase recalled the Bun fact through the full pipeline). Full pytest 1003 passed, 3 skipped. The provider still degrades to the deterministic 256-dim embedding if the server is down, so retrieval never breaks.
1 parent 74d8893 commit bef0e0d

4 files changed

Lines changed: 259 additions & 0 deletions

File tree

apps/api/app/embedding_server.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""OpenAI-compatible /v1/embeddings server backed by Qwen3-Embedding-0.6B.
2+
3+
Makes the semantic embeddings validated in ``app.benchmark.semantic_bench``
4+
deployable end-to-end: point ``MEMTRACE_EMBEDDING_PROVIDER=openai`` +
5+
``MEMTRACE_EMBEDDING_BASE_URL`` at this server and MemTrace's retrieval uses real
6+
semantic vectors instead of the deterministic hash default.
7+
8+
It honors the OpenAI ``dimensions`` parameter via Matryoshka (MRL) truncation +
9+
L2 renormalize, so its output matches the pgvector ``vector(256)`` column that
10+
``MEMTRACE_EMBEDDING_DIM`` defaults to. The model is loaded once at startup (warm)
11+
so requests never pay a cold-start; loading is FP16 on MPS/CUDA/CPU as available.
12+
13+
Heavy: needs ``sentence-transformers`` + ``torch`` and the model (~1.2GB). Run
14+
with an ephemeral install so nothing is added to the project deps:
15+
16+
uv run --with fastapi --with "uvicorn[standard]" --with sentence-transformers \
17+
uvicorn app.embedding_server:app --host 0.0.0.0 --port 8090
18+
19+
Config (env):
20+
QWEN3_EMBEDDING_MODEL (default Qwen/Qwen3-Embedding-0.6B)
21+
QWEN3_EMBEDDING_DIM (default 256; the MRL output dimension)
22+
QWEN3_EMBEDDING_WARM (default "1"; set "0" to defer model load to first request)
23+
"""
24+
from __future__ import annotations
25+
26+
import os
27+
from contextlib import asynccontextmanager
28+
from typing import Any
29+
30+
from fastapi import FastAPI, HTTPException
31+
from pydantic import BaseModel
32+
33+
_MODEL_ID = os.environ.get("QWEN3_EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-0.6B")
34+
_DEFAULT_DIM = int(os.environ.get("QWEN3_EMBEDDING_DIM", "256"))
35+
36+
37+
class _ModelHolder:
38+
"""Lazy singleton so importing this module never loads torch/the model."""
39+
40+
_model: Any = None
41+
_device: str = "unknown"
42+
43+
@classmethod
44+
def get(cls) -> Any:
45+
if cls._model is None:
46+
import torch
47+
from sentence_transformers import SentenceTransformer
48+
49+
if torch.backends.mps.is_available():
50+
cls._device = "mps"
51+
elif torch.cuda.is_available():
52+
cls._device = "cuda"
53+
else:
54+
cls._device = "cpu"
55+
cls._model = SentenceTransformer(
56+
_MODEL_ID, model_kwargs={"torch_dtype": torch.float16}, device=cls._device
57+
)
58+
return cls._model
59+
60+
61+
def _embed(texts: list[str], dim: int) -> list[list[float]]:
62+
import numpy as np
63+
64+
model = _ModelHolder.get()
65+
vecs = model.encode(texts, convert_to_numpy=True, normalize_embeddings=False)
66+
v = np.asarray(vecs, dtype="float32")
67+
if 0 < dim < v.shape[1]: # Matryoshka truncation to the requested dimension
68+
v = v[:, :dim]
69+
norms = np.linalg.norm(v, axis=1, keepdims=True)
70+
norms[norms == 0] = 1.0
71+
return (v / norms).tolist()
72+
73+
74+
class EmbeddingRequest(BaseModel):
75+
input: str | list[str]
76+
model: str = "qwen3-embedding-0.6b"
77+
dimensions: int | None = None
78+
encoding_format: str | None = None # accepted for OpenAI-client compatibility; only "float" is produced
79+
80+
81+
def create_app() -> FastAPI:
82+
@asynccontextmanager
83+
async def _lifespan(_app: FastAPI):
84+
if os.environ.get("QWEN3_EMBEDDING_WARM", "1") != "0":
85+
_ModelHolder.get() # warm so the first request isn't a cold start
86+
yield
87+
88+
app = FastAPI(title="Qwen3 Embedding Server", lifespan=_lifespan)
89+
90+
@app.get("/health")
91+
def health() -> dict[str, Any]:
92+
return {"status": "ok", "model": _MODEL_ID, "default_dim": _DEFAULT_DIM,
93+
"loaded": _ModelHolder._model is not None, "device": _ModelHolder._device}
94+
95+
@app.post("/v1/embeddings")
96+
def embeddings(req: EmbeddingRequest) -> dict[str, Any]:
97+
texts = [req.input] if isinstance(req.input, str) else list(req.input)
98+
if not texts or any(not isinstance(t, str) for t in texts):
99+
raise HTTPException(status_code=400, detail="input must be a non-empty string or list of strings")
100+
dim = req.dimensions if (req.dimensions and req.dimensions > 0) else _DEFAULT_DIM
101+
try:
102+
vectors = _embed(texts, dim)
103+
except Exception as exc: # noqa: BLE001
104+
raise HTTPException(status_code=500, detail=f"embedding failed: {type(exc).__name__}: {exc}")
105+
data = [{"object": "embedding", "index": i, "embedding": vec} for i, vec in enumerate(vectors)]
106+
return {"object": "list", "data": data, "model": req.model,
107+
"usage": {"prompt_tokens": 0, "total_tokens": 0}}
108+
109+
return app
110+
111+
112+
app = create_app()
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Structural tests for the Qwen3 embedding server (no model download/load)."""
2+
from __future__ import annotations
3+
4+
from fastapi.testclient import TestClient
5+
6+
from app.embedding_server import EmbeddingRequest, create_app
7+
8+
9+
def test_health_and_empty_input_validation(monkeypatch):
10+
monkeypatch.setenv("QWEN3_EMBEDDING_WARM", "0") # do not load torch/the model
11+
app = create_app()
12+
with TestClient(app) as client:
13+
health = client.get("/health")
14+
assert health.status_code == 200
15+
body = health.json()
16+
assert body["default_dim"] == 256
17+
assert body["loaded"] is False # warm disabled -> model not loaded
18+
# empty input is rejected before any model work
19+
resp = client.post("/v1/embeddings", json={"input": []})
20+
assert resp.status_code == 400
21+
22+
23+
def test_request_model_shape():
24+
req = EmbeddingRequest(input="hello", dimensions=256)
25+
assert req.dimensions == 256
26+
assert EmbeddingRequest(input=["a", "b"]).dimensions is None

docs/deployment.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,34 @@ uv run python -m app.benchmark.llm_bench --output-dir reports
182182

183183
Do not make real provider calls part of default CI or first-time quickstarts.
184184

185+
### Real semantic embeddings — Qwen3 embedding server
186+
187+
The default `deterministic` embedding is a lexical hash (~42% recall@1 on
188+
paraphrased queries); a real semantic model gets ~92% (see
189+
`app/benchmark/semantic_bench.py`). `app/embedding_server.py` is a turnkey
190+
OpenAI-compatible `/v1/embeddings` server backed by Qwen3-Embedding-0.6B (FP16),
191+
honoring the `dimensions` param via Matryoshka truncation to the pgvector 256-dim
192+
column. Start it (heavy; needs the model, ~1.2GB) and point the runtime at it:
193+
194+
```bash
195+
# 1. serve Qwen3 as an OpenAI-compatible /v1/embeddings endpoint
196+
uv run --with fastapi --with "uvicorn[standard]" --with sentence-transformers \
197+
uvicorn app.embedding_server:app --app-dir apps/api --host 0.0.0.0 --port 8090
198+
199+
# 2. verify end-to-end through MemTrace's provider (skips if the server is down)
200+
MEMTRACE_EMBEDDING_BASE_URL=http://localhost:8090/v1 ./scripts/smoke-embedding-server.sh
201+
202+
# 3. wire the running MemTrace service to it (real semantic retrieval)
203+
export MEMTRACE_EMBEDDING_PROVIDER=openai
204+
export MEMTRACE_EMBEDDING_BASE_URL=http://localhost:8090/v1
205+
export MEMTRACE_EMBEDDING_MODEL=qwen3-embedding-0.6b
206+
export MEMTRACE_EMBEDDING_API_KEY=local # the server does not check auth; the provider sends a Bearer
207+
```
208+
209+
The provider degrades to the deterministic 256-dim embedding on any failure, so
210+
retrieval never breaks if the server is down. For production, run the embedding
211+
server as its own scaled service (GPU or CPU) behind the same load balancer.
212+
185213
## Optional telemetry export
186214

187215
OpenTelemetry/OpenInference-compatible export is default-off and must be enabled explicitly. Local JSONL output is the safest no-network smoke path:

scripts/smoke-embedding-server.sh

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
#!/usr/bin/env bash
2+
# Opt-in end-to-end smoke for the Qwen3 OpenAI-compatible embedding server
3+
# (app/embedding_server.py). Start the server first, e.g.:
4+
# uv run --with fastapi --with "uvicorn[standard]" --with sentence-transformers \
5+
# uvicorn app.embedding_server:app --app-dir apps/api --port 8090
6+
# then:
7+
# MEMTRACE_EMBEDDING_BASE_URL=http://localhost:8090/v1 ./scripts/smoke-embedding-server.sh
8+
# It verifies, through MemTrace's real OpenAIEmbeddingProvider, that (1) the server
9+
# returns 256-dim vectors and (2) a full retrieve_context wired to this provider
10+
# recalls the semantically-matched memory for a paraphrased query. Skips cleanly
11+
# when the server is unreachable. NOT part of default CI.
12+
set -euo pipefail
13+
14+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15+
cd "$ROOT_DIR/apps/api"
16+
17+
echo "==> Qwen3 embedding-server smoke (${MEMTRACE_EMBEDDING_BASE_URL:-http://localhost:8090/v1})"
18+
19+
uv run python - <<'PY'
20+
import asyncio
21+
import os
22+
import sys
23+
24+
import httpx
25+
26+
from app.providers.base import ProviderCapabilities, ProviderKind
27+
from app.providers.embedding import OpenAIEmbeddingProvider
28+
from app.providers.registry import ProviderRegistry
29+
from app.runtime.memory_runtime import MemoryRuntime
30+
from app.runtime.models import (
31+
MemoryItem, MemoryScope, MemoryType, RetrievalRequest, RetrievalStrategy,
32+
StartRunRequest, StartStepRequest,
33+
)
34+
from app.runtime.repository import InMemoryRepository
35+
36+
BASE = os.environ.get("MEMTRACE_EMBEDDING_BASE_URL", "http://localhost:8090/v1").rstrip("/")
37+
ROOT = BASE[:-3] if BASE.endswith("/v1") else BASE # derive the server root for /health
38+
39+
FACTS = {
40+
"runtime": "The project uses Bun as its JavaScript runtime.",
41+
"db": "PostgreSQL is the primary relational database.",
42+
"deploy": "We ship the service to a Kubernetes cluster.",
43+
"cache": "Redis provides the in-memory caching layer.",
44+
"auth": "Users authenticate with JSON Web Tokens.",
45+
}
46+
PARAPHRASE = "What executes the client-side script code in this repo?" # -> runtime (Bun)
47+
48+
49+
async def main() -> int:
50+
try:
51+
httpx.get(f"{ROOT}/health", timeout=5).raise_for_status()
52+
except Exception as exc: # noqa: BLE001
53+
print(f" ⏭️ skip: embedding server not reachable at {ROOT}/health ({type(exc).__name__})")
54+
print("advanced-embedding smoke skipped")
55+
return 0
56+
57+
provider = OpenAIEmbeddingProvider(api_key="local", base_url=BASE,
58+
model="qwen3-embedding-0.6b", dimensions=256, timeout_s=60)
59+
try:
60+
# 1) the server returns a finite 256-dim vector through MemTrace's provider
61+
vec = await provider.embed_text(PARAPHRASE)
62+
assert len(vec) == 256, f"expected 256 dims, got {len(vec)}"
63+
print(f" ✅ provider: server returned a {len(vec)}-dim vector via MemTrace's OpenAIEmbeddingProvider")
64+
65+
# 2) full retrieval wired to this provider recalls the paraphrase's semantic target
66+
registry = ProviderRegistry()
67+
registry.register(ProviderKind.embedding, provider, ProviderCapabilities(
68+
provider_id="embedding.qwen3_server.v1", kind=ProviderKind.embedding,
69+
deterministic=False, requires_network=True, model="qwen3-embedding-0.6b",
70+
metadata={"dim": 256}))
71+
repo = InMemoryRepository()
72+
ws = "emb_smoke_ws"
73+
for fid, text in FACTS.items():
74+
fv = await provider.embed_text(text) # store the real semantic vector
75+
await repo.add_memory(MemoryItem(memory_id=f"m_{fid}", workspace_id=ws,
76+
memory_type=MemoryType.project, scope=MemoryScope.workspace,
77+
content=text, summary=text[:60], embedding_vector=fv))
78+
rt = MemoryRuntime(repo, default_workspace_id=ws, provider_registry=registry)
79+
run = await rt.start_run(StartRunRequest(session_id="emb", task="emb", workspace_id=ws))
80+
step = await rt.start_step(StartStepRequest(run_id=run.run_id, intent="answer"))
81+
ctx = await rt.retrieve_context(RetrievalRequest(
82+
run_id=run.run_id, step_id=step.step_id, query=PARAPHRASE, strategy=RetrievalStrategy.variant_2))
83+
text = " ".join((b.content or "") for b in ctx.context_blocks)
84+
assert "Bun" in text, f"paraphrase did not recall the runtime fact; context={text[:200]!r}"
85+
print(" ✅ retrieval: full retrieve_context via the real provider recalled the Bun fact for a paraphrase")
86+
finally:
87+
await provider.aclose()
88+
print("advanced-embedding smoke passed")
89+
return 0
90+
91+
92+
sys.exit(asyncio.run(main()))
93+
PY

0 commit comments

Comments
 (0)