feat: embed on the worker, and stop batching the ONNX pass

The API embeds every query it serves, so it held its own copy of the model:
~890 MB it never needed. EMBEDDINGS_DELEGATE_TO_WORKER (on by default) sends
the text to the Celery worker instead and gets the vector back, taking an API
process from 1176 MB to 285 MB with no ONNX Runtime imported at all. The client
embeds locally when it finds itself inside a worker task, so the worker never
dispatches to itself -- the same self-deadlock DOCUMENT_PARSE_QUEUE avoids on
the parsing side. EMBEDDINGS_BASE_URL still wins over it, and remains the right
answer for production.

ensure_vector_schema was constructing the embeddings instance purely to read
.dimension off it, loading several hundred MB of ONNX into every API and worker
process at import. For a model the registry describes that is a lookup; only an
unregistered name now falls back to loading.

EMBEDDINGS_BATCH_SIZE was sizing two unrelated things: chunks per store
transaction (and per remote embed request) and documents per ONNX forward pass.
Each pass pads every input up to its longest, and that waste grows with the
square of chunk length, so at the 1250-token default a batch of 32 peaked at
6.6 GB and took 326s where a batch of 1 peaked at 2.9 GB and took 90s. The
forward pass is now sized by EMBEDDINGS_MODEL_BATCH_SIZE, defaulting to 1;
storage and remote batching are unchanged at 32.

reembed embeds in-process: a batch job that walks the whole index should not
round-trip every chunk through a broker, and loading the model there reports a
real failure instead of timing out against an empty queue.

Also drops the mpnet zip download from the docs and the devcontainer, which
pointed at a SentenceTransformers export with no ONNX graph and had been inert
since the FastEmbed swap; corrects the claim that any sentence-transformers
model works; and settles the Configuring/Settings pages on what the registry
and the repository metadata actually decide.
This commit is contained in:
Alex committed 2026-08-28 12:23:18 +01:00
1 parent ef51ee97b3
commit 47e53a71c4
20 files changed
+679 -266

No files matched your search

@@ -0,0 +1,130 @@
"""Query embedding runs on the worker so the API holds no model."""
from unittest.mock import MagicMock, patch
import pytest
from application.vectorstore import base
from application.vectorstore.embeddings_delegated import EMBED_TASK, DelegatedEmbeddings
@pytest.fixture(autouse=True)
def _clear_singleton():
base.EmbeddingsSingleton._instances.clear()
yield
base.EmbeddingsSingleton._instances.clear()
@pytest.fixture
def not_in_worker():
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=False):
yield
class TestDispatch:
def test_query_is_embedded_on_the_worker(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.1, 0.2, 0.3]]
with patch("application.celery_init.celery", celery):
vector = DelegatedEmbeddings("some/model").embed_query("hello")
assert vector == [0.1, 0.2, 0.3]
assert celery.send_task.call_args.args[0] == EMBED_TASK
assert celery.send_task.call_args.kwargs["args"] == [["hello"], "some/model"]
def test_routed_to_the_embeddings_queue(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.0]]
with patch("application.celery_init.celery", celery):
with patch.object(base.settings, "EMBEDDINGS_QUEUE", "embeddings"):
DelegatedEmbeddings("some/model").embed_query("hi")
assert celery.send_task.call_args.kwargs["queue"] == "embeddings"
def test_no_worker_gives_an_actionable_error(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.side_effect = TimeoutError("no worker")
with patch("application.celery_init.celery", celery):
with pytest.raises(RuntimeError) as excinfo:
DelegatedEmbeddings("some/model").embed_query("hi")
message = str(excinfo.value)
assert "EMBEDDINGS_DELEGATE_TO_WORKER=false" in message
assert "EMBEDDINGS_BASE_URL" in message
def test_empty_input_never_reaches_the_broker(self, not_in_worker):
celery = MagicMock()
with patch("application.celery_init.celery", celery):
assert DelegatedEmbeddings("some/model").embed_documents([]) == []
celery.send_task.assert_not_called()
class TestInsideAWorker:
"""Dispatching from inside a task would queue work behind itself."""
def test_a_running_task_embeds_locally(self):
local = MagicMock()
local.embed_documents.return_value = [[1.0, 2.0]]
celery = MagicMock()
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=True):
with patch("application.vectorstore.base.build_local_embeddings", return_value=local):
with patch("application.celery_init.celery", celery):
vector = DelegatedEmbeddings("some/model").embed_query("hi")
assert vector == [1.0, 2.0]
celery.send_task.assert_not_called()
def test_the_local_model_is_built_once(self):
local = MagicMock()
local.embed_documents.return_value = [[1.0]]
builder = MagicMock(return_value=local)
client = DelegatedEmbeddings("some/model")
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=True):
with patch("application.vectorstore.base.build_local_embeddings", builder):
client.embed_query("a")
client.embed_query("b")
builder.assert_called_once()
class TestDimension:
def test_registry_width_costs_no_round_trip(self):
celery = MagicMock()
with patch("application.celery_init.celery", celery):
client = DelegatedEmbeddings("ibm-granite/granite-embedding-311m-multilingual-r2")
assert client.dimension == 768
celery.send_task.assert_not_called()
def test_unknown_width_is_probed_once(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.0] * 1024]
with patch("application.celery_init.celery", celery):
client = DelegatedEmbeddings("some/unregistered")
assert client.dimension == 1024
assert client.dimension == 1024
celery.send_task.assert_called_once()
def test_an_unreachable_worker_reports_no_width(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.side_effect = TimeoutError("down")
with patch("application.celery_init.celery", celery):
assert DelegatedEmbeddings("some/unregistered").dimension is None
class TestGetEmbeddingsDispatch:
def test_delegates_when_enabled(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert isinstance(base.get_embeddings("some/model"), DelegatedEmbeddings)
def test_remote_url_wins_over_delegation(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", "http://embed.local"):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert isinstance(base.get_embeddings("some/model"), base.RemoteEmbeddings)
def test_disabled_loads_in_process(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False):
with patch.object(base.EmbeddingsSingleton, "get_instance") as get_instance:
base.get_embeddings("some/model")
get_instance.assert_called_once()
def test_the_delegating_client_is_shared(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert base.get_embeddings("some/model") is base.get_embeddings("some/model")
+4 -4
View File
@@ -197,7 +197,7 @@ class TestLengthSortedBatching:
return wrapper, instance
def test_output_order_matches_input_order(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
texts = ["dddd", "a", "ccc", "bb", "eeeee"]
out = wrapper.embed_documents(texts)
@@ -206,14 +206,14 @@ class TestLengthSortedBatching:
assert out == [[4.0], [1.0], [3.0], [2.0], [5.0]]
def test_inputs_are_grouped_by_length_before_batching(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 2)
wrapper.embed_documents(["dddd", "a", "ccc", "bb", "eeeee"])
sent = instance.embed.call_args.args[0]
assert [len(t) for t in sent] == [1, 2, 3, 4, 5]
def test_single_batch_is_not_reordered(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 32, create=True):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 32, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 32)
texts = ["dddd", "a", "ccc"]
out = wrapper.embed_documents(texts)
@@ -221,7 +221,7 @@ class TestLengthSortedBatching:
assert out == [[4.0], [1.0], [3.0]]
def test_duplicate_texts_are_handled(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
out = wrapper.embed_documents(["aa", "b", "aa", "ccc"])
assert out == [[2.0], [1.0], [2.0], [3.0]]
@@ -103,9 +103,20 @@ def live_dsn(postgresql, monkeypatch):
@pytest.fixture
def stub_embeddings():
"""Stand in for the configured model everywhere its width is read.
``ensure_vector_schema`` takes the width from the registry rather than by
constructing the model, so patching only the constructors would leave the
boot hook sizing the table from whatever EMBEDDINGS_NAME happens to be.
"""
stub = _StubEmbeddings()
with patch(
"application.vectorstore.base.get_embeddings", return_value=stub
), patch(
"application.vectorstore.base.build_local_embeddings", return_value=stub
), patch(
"application.vectorstore.model_registry.dimension_for",
return_value=STUB_DIM,
), patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=stub,
@@ -140,6 +151,9 @@ class TestBootHookCreatesTheSchema:
wide = _WideStubEmbeddings()
with patch(
"application.vectorstore.base.get_embeddings", return_value=wide
), patch(
"application.vectorstore.model_registry.dimension_for",
return_value=wide.dimension,
):
with pytest.raises(RuntimeError) as excinfo:
ensure_vector_schema()