mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-19 04:15:32 +00:00
feat: optimise embeds
This commit is contained in:
1 parent
e0aff39a1b
commit
8380f9bb47
28 files changed
+1992
-349
No files matched your search
@@ -263,11 +263,11 @@ class TestEmbeddingsSingleton:
|
||||
def test_get_instance_hf_ignores_positional_key(
|
||||
self, mock_get_wrapper, mock_settings
|
||||
):
|
||||
"""A stray key must not reach the zero-arg HuggingFace factory.
|
||||
"""A stray key must not reach the wrapper for a registered model.
|
||||
|
||||
The factories are ``lambda: EmbeddingsWrapper(...)``, so a caller that
|
||||
passed ``settings.EMBEDDINGS_KEY`` positionally used to blow up with
|
||||
``TypeError: <lambda>() takes 0 positional arguments``.
|
||||
Registered models take their whole configuration from the registry, so
|
||||
a caller that passes ``settings.EMBEDDINGS_KEY`` positionally (as the
|
||||
vector stores do) must have it dropped rather than forwarded.
|
||||
"""
|
||||
mock_settings.EMBEDDINGS_BASE_URL = None
|
||||
mock_wrapper_cls = Mock()
|
||||
@@ -278,9 +278,8 @@ class TestEmbeddingsSingleton:
|
||||
result = EmbeddingsSingleton.get_instance(HF_MPNET, None)
|
||||
|
||||
assert result is mock_instance
|
||||
mock_wrapper_cls.assert_called_once_with(
|
||||
"sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
# The configured name is passed through; the registry maps it to a repo.
|
||||
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
|
||||
|
||||
@patch("application.vectorstore.base.settings")
|
||||
@patch("application.vectorstore.base._get_embeddings_wrapper")
|
||||
@@ -293,9 +292,7 @@ class TestEmbeddingsSingleton:
|
||||
|
||||
EmbeddingsSingleton.get_instance(HF_MPNET, openai_api_key="sk-nope")
|
||||
|
||||
mock_wrapper_cls.assert_called_once_with(
|
||||
"sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
|
||||
|
||||
|
||||
# --- BaseVectorStore ---
|
||||
@@ -401,12 +398,15 @@ class TestBaseVectorStore:
|
||||
|
||||
@patch("application.vectorstore.base.settings")
|
||||
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
|
||||
@patch("os.path.exists")
|
||||
def test_get_embeddings_huggingface_local_model(
|
||||
self, mock_exists, mock_get_instance, mock_settings
|
||||
def test_get_embeddings_registered_model_passes_configured_name(
|
||||
self, mock_get_instance, mock_settings
|
||||
):
|
||||
"""No bundled-path branch any more: the name goes straight through.
|
||||
|
||||
FastEmbed resolves artifacts through its own cache (warmed in the
|
||||
image), so the old ``/app/models/...`` probe has no job to do.
|
||||
"""
|
||||
mock_settings.EMBEDDINGS_BASE_URL = None
|
||||
mock_exists.side_effect = lambda p: p == "/app/models/all-mpnet-base-v2"
|
||||
mock_emb = Mock()
|
||||
mock_get_instance.return_value = mock_emb
|
||||
|
||||
@@ -415,7 +415,9 @@ class TestBaseVectorStore:
|
||||
"huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
assert result is mock_emb
|
||||
mock_get_instance.assert_called_with("/app/models/all-mpnet-base-v2")
|
||||
mock_get_instance.assert_called_with(
|
||||
"huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
|
||||
@patch("application.vectorstore.base.settings")
|
||||
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
|
||||
@@ -518,18 +520,18 @@ class TestGetEmbeddingsResolver:
|
||||
|
||||
@patch("application.vectorstore.base.settings")
|
||||
@patch("application.vectorstore.base._get_embeddings_wrapper")
|
||||
@patch("os.path.exists")
|
||||
def test_uses_local_model_path_and_caches_it(
|
||||
self, mock_exists, mock_get_wrapper, mock_settings
|
||||
def test_repeated_resolution_loads_one_model(
|
||||
self, mock_get_wrapper, mock_settings
|
||||
):
|
||||
"""With the bundled model present, the instance is keyed by its path.
|
||||
"""A second call must not load a second copy of the model.
|
||||
|
||||
A second call must not load a second copy of the model.
|
||||
The instance is keyed by the configured name. It used to be keyed by a
|
||||
bundled filesystem path when one happened to exist, which meant the
|
||||
same model could be cached twice under two keys.
|
||||
"""
|
||||
mock_settings.EMBEDDINGS_BASE_URL = None
|
||||
mock_settings.EMBEDDINGS_NAME = HF_MPNET
|
||||
mock_settings.EMBEDDINGS_KEY = None
|
||||
mock_exists.side_effect = lambda path: path == LOCAL_MPNET
|
||||
mock_wrapper_cls = Mock()
|
||||
mock_wrapper_cls.return_value = Mock()
|
||||
mock_get_wrapper.return_value = mock_wrapper_cls
|
||||
@@ -538,8 +540,8 @@ class TestGetEmbeddingsResolver:
|
||||
second = get_embeddings()
|
||||
|
||||
assert first is second
|
||||
assert set(EmbeddingsSingleton._instances) == {LOCAL_MPNET}
|
||||
mock_wrapper_cls.assert_called_once_with(LOCAL_MPNET)
|
||||
assert set(EmbeddingsSingleton._instances) == {HF_MPNET}
|
||||
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
|
||||
|
||||
@patch("application.vectorstore.base.settings")
|
||||
def test_remote_when_base_url_configured(self, mock_settings):
|
||||
|
||||
@@ -1,156 +1,167 @@
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
"""Local embeddings run through FastEmbed, configured from the model registry."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from application.vectorstore import embeddings_local
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
from application.vectorstore.model_registry import GRANITE_97M, MPNET
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestEmbeddingsWrapper:
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_init_success(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_embedding_dimension.return_value = 768
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_registration():
|
||||
"""``add_custom_model`` writes to a FastEmbed global; keep tests isolated."""
|
||||
embeddings_local._registered.clear()
|
||||
yield
|
||||
embeddings_local._registered.clear()
|
||||
|
||||
wrapper = EmbeddingsWrapper("test-model")
|
||||
|
||||
mock_st_cls.assert_called_once()
|
||||
assert wrapper.dimension == 768
|
||||
@pytest.fixture
|
||||
def fake_fastembed():
|
||||
"""Patch FastEmbed so no model is downloaded or run."""
|
||||
text_embedding = MagicMock()
|
||||
instance = MagicMock()
|
||||
instance.embed.return_value = iter([np.array([0.1, 0.2, 0.3])])
|
||||
text_embedding.return_value = instance
|
||||
with patch("fastembed.TextEmbedding", text_embedding):
|
||||
yield text_embedding, instance
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_init_falls_back_to_legacy_dimension_api(self, mock_st_cls):
|
||||
"""sentence-transformers < 5.4 only exposes get_sentence_embedding_dimension."""
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
del mock_model.get_embedding_dimension
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 768
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
class TestRegistryDrivenLoading:
|
||||
def test_registered_model_loads_by_repo_not_by_configured_name(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
|
||||
assert wrapper.dimension == MPNET.dimension
|
||||
|
||||
wrapper = EmbeddingsWrapper("test-model")
|
||||
def test_legacy_alias_resolves_to_the_same_model(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
EmbeddingsWrapper("huggingface_sentence-transformers-all-mpnet-base-v2")
|
||||
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
|
||||
|
||||
assert wrapper.dimension == 768
|
||||
mock_model.get_sentence_embedding_dimension.assert_called_once()
|
||||
def test_dimension_comes_from_registry_without_running_the_model(self, fake_fastembed):
|
||||
_, instance = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper(GRANITE_97M.name)
|
||||
assert wrapper.dimension == 384
|
||||
instance.embed.assert_not_called()
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_init_failure(self, mock_st_cls):
|
||||
mock_st_cls.side_effect = Exception("model not found")
|
||||
def test_unknown_model_is_treated_as_a_hf_repo(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper("some-org/custom-embedder")
|
||||
assert text_embedding.call_args.kwargs["model_name"] == "some-org/custom-embedder"
|
||||
# No registry entry means no known width, so it must be probed.
|
||||
assert wrapper.dimension == 3
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
def test_load_failure_names_the_model_and_the_known_ones(self):
|
||||
with patch("fastembed.TextEmbedding", side_effect=OSError("no such repo")):
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
EmbeddingsWrapper("broken/model")
|
||||
message = str(excinfo.value)
|
||||
assert "broken/model" in message
|
||||
assert MPNET.name in message
|
||||
|
||||
with pytest.raises(Exception, match="model not found"):
|
||||
EmbeddingsWrapper("bad-model")
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_init_none_model(self, mock_st_cls):
|
||||
mock_st_cls.return_value = None
|
||||
class TestSettingsPassthrough:
|
||||
def test_threads_forwarded_when_configured(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", 2, create=True):
|
||||
EmbeddingsWrapper(MPNET.name)
|
||||
assert text_embedding.call_args.kwargs["threads"] == 2
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
def test_threads_omitted_when_unset(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", None, create=True):
|
||||
EmbeddingsWrapper(MPNET.name)
|
||||
assert "threads" not in text_embedding.call_args.kwargs
|
||||
|
||||
with pytest.raises((ValueError, AttributeError)):
|
||||
EmbeddingsWrapper("bad-model")
|
||||
def test_cache_dir_forwarded_when_configured(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_CACHE_DIR", "/models", create=True):
|
||||
EmbeddingsWrapper(MPNET.name)
|
||||
assert text_embedding.call_args.kwargs["cache_dir"] == "/models"
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_init_null_first_module(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = None
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
class TestEmbedding:
|
||||
def test_embed_documents_returns_plain_lists(self, fake_fastembed):
|
||||
_, instance = fake_fastembed
|
||||
instance.embed.return_value = iter([np.array([1.0, 2.0]), np.array([3.0, 4.0])])
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
assert wrapper.embed_documents(["a", "b"]) == [[1.0, 2.0], [3.0, 4.0]]
|
||||
|
||||
with pytest.raises(ValueError, match="failed to load properly"):
|
||||
EmbeddingsWrapper("bad-model")
|
||||
def test_embed_documents_short_circuits_on_empty_input(self, fake_fastembed):
|
||||
_, instance = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
instance.embed.reset_mock()
|
||||
assert wrapper.embed_documents([]) == []
|
||||
instance.embed.assert_not_called()
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_embed_query(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 3
|
||||
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1, 0.2, 0.3]))
|
||||
mock_st_cls.return_value = mock_model
|
||||
def test_embed_query_returns_a_single_vector(self, fake_fastembed):
|
||||
_, instance = fake_fastembed
|
||||
instance.embed.return_value = iter([np.array([0.5, 0.6])])
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
assert wrapper.embed_query("hello") == [0.5, 0.6]
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
def test_call_dispatches_on_input_type(self, fake_fastembed):
|
||||
_, instance = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
instance.embed.return_value = iter([np.array([1.0])])
|
||||
assert wrapper("text") == [1.0]
|
||||
instance.embed.return_value = iter([np.array([1.0]), np.array([2.0])])
|
||||
assert wrapper(["a", "b"]) == [[1.0], [2.0]]
|
||||
|
||||
wrapper = EmbeddingsWrapper("model")
|
||||
result = wrapper.embed_query("hello world")
|
||||
def test_call_rejects_other_types(self, fake_fastembed):
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
with pytest.raises(ValueError):
|
||||
wrapper(42)
|
||||
|
||||
mock_model.encode.assert_called_once_with("hello world")
|
||||
assert result == [0.1, 0.2, 0.3]
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_embed_documents(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 3
|
||||
mock_model.encode.return_value = MagicMock(
|
||||
tolist=Mock(return_value=[[0.1, 0.2], [0.3, 0.4]])
|
||||
class TestRegistrationIsIdempotent:
|
||||
def test_model_registered_once_per_process(self, fake_fastembed):
|
||||
text_embedding, _ = fake_fastembed
|
||||
EmbeddingsWrapper(MPNET.name)
|
||||
EmbeddingsWrapper(MPNET.name)
|
||||
assert text_embedding.add_custom_model.call_count == 1
|
||||
|
||||
|
||||
class TestLengthSortedBatching:
|
||||
"""Grouping by length is a throughput/memory win, but order is a contract."""
|
||||
|
||||
def _wrapper(self, fake_fastembed, batch_size):
|
||||
_, instance = fake_fastembed
|
||||
wrapper = EmbeddingsWrapper(MPNET.name)
|
||||
instance.embed.side_effect = lambda texts, batch_size=None: iter(
|
||||
[np.array([float(len(t))]) for t in texts]
|
||||
)
|
||||
mock_st_cls.return_value = mock_model
|
||||
return wrapper, instance
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
def test_output_order_matches_input_order(self, fake_fastembed):
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 2, create=True):
|
||||
wrapper, _ = self._wrapper(fake_fastembed, 2)
|
||||
texts = ["dddd", "a", "ccc", "bb", "eeeee"]
|
||||
out = wrapper.embed_documents(texts)
|
||||
# Each stub vector encodes its own text length, so a reordered result
|
||||
# is immediately visible.
|
||||
assert out == [[4.0], [1.0], [3.0], [2.0], [5.0]]
|
||||
|
||||
wrapper = EmbeddingsWrapper("model")
|
||||
result = wrapper.embed_documents(["doc1", "doc2"])
|
||||
def test_inputs_are_grouped_by_length_before_batching(self, fake_fastembed):
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_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]
|
||||
|
||||
mock_model.encode.assert_called_with(["doc1", "doc2"])
|
||||
assert result == [[0.1, 0.2], [0.3, 0.4]]
|
||||
def test_single_batch_is_not_reordered(self, fake_fastembed):
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_BATCH_SIZE", 32, create=True):
|
||||
wrapper, instance = self._wrapper(fake_fastembed, 32)
|
||||
texts = ["dddd", "a", "ccc"]
|
||||
out = wrapper.embed_documents(texts)
|
||||
assert instance.embed.call_args.args[0] == texts
|
||||
assert out == [[4.0], [1.0], [3.0]]
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_call_with_string(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 3
|
||||
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1]))
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
|
||||
wrapper = EmbeddingsWrapper("model")
|
||||
result = wrapper("hello")
|
||||
assert result == [0.1]
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_call_with_list(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 3
|
||||
mock_model.encode.return_value = MagicMock(
|
||||
tolist=Mock(return_value=[[0.1], [0.2]])
|
||||
)
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
|
||||
wrapper = EmbeddingsWrapper("model")
|
||||
result = wrapper(["a", "b"])
|
||||
assert result == [[0.1], [0.2]]
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_call_with_invalid_type(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 3
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
|
||||
wrapper = EmbeddingsWrapper("model")
|
||||
with pytest.raises(ValueError, match="Input must be a string or a list"):
|
||||
wrapper(123)
|
||||
|
||||
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
|
||||
def test_trust_remote_code_default(self, mock_st_cls):
|
||||
mock_model = MagicMock()
|
||||
mock_model._first_module.return_value = MagicMock()
|
||||
mock_model.get_sentence_embedding_dimension.return_value = 768
|
||||
mock_st_cls.return_value = mock_model
|
||||
|
||||
from application.vectorstore.embeddings_local import EmbeddingsWrapper
|
||||
|
||||
EmbeddingsWrapper("model")
|
||||
|
||||
call_kwargs = mock_st_cls.call_args[1]
|
||||
assert call_kwargs["trust_remote_code"] is True
|
||||
def test_duplicate_texts_are_handled(self, fake_fastembed):
|
||||
with patch.object(embeddings_local.settings, "EMBEDDINGS_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]]
|
||||
@@ -185,15 +185,20 @@ class TestFaissStoreAssertEmbeddingDimensions:
|
||||
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
|
||||
populated.assert_embedding_dimensions(Mock(dimension=768))
|
||||
|
||||
def test_missing_dimension_attr_raises(self, populated):
|
||||
def test_unknown_dimension_defers_rather_than_raising(self, populated):
|
||||
"""A remote model reports no width until its first call.
|
||||
|
||||
Refusing to open the index in that window would break startup for a
|
||||
perfectly valid remote configuration, so an unknown width is deferred,
|
||||
not treated as a mismatch.
|
||||
"""
|
||||
with patch("application.vectorstore.faiss.settings") as mock_settings:
|
||||
mock_settings.EMBEDDINGS_NAME = (
|
||||
"huggingface_sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
embeddings = Mock()
|
||||
del embeddings.dimension
|
||||
with pytest.raises(AttributeError, match="'dimension' attribute not found"):
|
||||
populated.assert_embedding_dimensions(embeddings)
|
||||
embeddings.dimension = None
|
||||
assert populated.assert_embedding_dimensions(embeddings) is None
|
||||
|
||||
def test_dimension_match_passes(self, populated):
|
||||
with patch("application.vectorstore.faiss.settings") as mock_settings:
|
||||
@@ -202,10 +207,22 @@ class TestFaissStoreAssertEmbeddingDimensions:
|
||||
)
|
||||
assert populated.assert_embedding_dimensions(Mock(dimension=3)) is None
|
||||
|
||||
def test_non_huggingface_skips_dimension_check(self, populated):
|
||||
def test_mismatch_is_caught_for_every_model_not_just_mpnet(self, populated):
|
||||
"""The check used to run only when EMBEDDINGS_NAME was mpnet.
|
||||
|
||||
That skipped exactly the case it exists for: an index built with one
|
||||
model being opened under a different one.
|
||||
"""
|
||||
with patch("application.vectorstore.faiss.settings") as mock_settings:
|
||||
mock_settings.EMBEDDINGS_NAME = "openai_text-embedding-ada-002"
|
||||
assert populated.assert_embedding_dimensions(Mock(dimension=1536)) is None
|
||||
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
|
||||
populated.assert_embedding_dimensions(Mock(dimension=1536))
|
||||
|
||||
def test_mismatch_message_points_at_the_reembed_script(self, populated):
|
||||
with patch("application.vectorstore.faiss.settings") as mock_settings:
|
||||
mock_settings.EMBEDDINGS_NAME = "granite-311m"
|
||||
with pytest.raises(ValueError, match="application.scripts.reembed"):
|
||||
populated.assert_embedding_dimensions(Mock(dimension=768))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""The registry is the single source of truth for embedding-model facts."""
|
||||
|
||||
import pytest
|
||||
|
||||
from application.vectorstore import model_registry as reg
|
||||
|
||||
|
||||
class TestResolve:
|
||||
def test_resolves_canonical_name(self):
|
||||
assert reg.resolve(reg.MPNET.name) is reg.MPNET
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias",
|
||||
[
|
||||
"huggingface_sentence-transformers-all-mpnet-base-v2",
|
||||
"sentence-transformers/all-mpnet-base-v2",
|
||||
"all-mpnet-base-v2",
|
||||
],
|
||||
)
|
||||
def test_resolves_legacy_spellings_of_mpnet(self, alias):
|
||||
"""Every spelling the old factory dict accepted must still work."""
|
||||
assert reg.resolve(alias) is reg.MPNET
|
||||
|
||||
def test_resolution_is_case_insensitive_and_trims(self):
|
||||
assert reg.resolve(" GRANITE-311M ") is reg.GRANITE_311M
|
||||
|
||||
def test_unknown_name_is_none_not_an_error(self):
|
||||
"""Unknown names are a valid configuration: an arbitrary HF repo."""
|
||||
assert reg.resolve("some-org/some-model") is None
|
||||
|
||||
def test_empty_and_none_resolve_to_none(self):
|
||||
assert reg.resolve(None) is None
|
||||
assert reg.resolve("") is None
|
||||
|
||||
|
||||
class TestModelFacts:
|
||||
def test_granite_311m_matches_the_existing_column_width(self):
|
||||
"""768 is what makes granite a drop-in for an mpnet index."""
|
||||
assert reg.GRANITE_311M.dimension == reg.MPNET.dimension == 768
|
||||
|
||||
def test_granite_97m_is_narrower(self):
|
||||
assert reg.GRANITE_97M.dimension == 384
|
||||
|
||||
def test_pooling_and_normalisation_are_recorded(self):
|
||||
assert reg.MPNET.pooling == "mean"
|
||||
assert reg.GRANITE_311M.pooling == "cls"
|
||||
assert all(m.normalize for m in reg.MODELS)
|
||||
|
||||
def test_granite_context_is_much_wider_than_mpnet(self):
|
||||
assert reg.GRANITE_311M.max_input_tokens == 32768
|
||||
assert reg.MPNET.max_input_tokens == 384
|
||||
|
||||
def test_local_runners_carry_a_repo_and_onnx_file(self):
|
||||
for model in reg.MODELS:
|
||||
if model.provider == "fastembed":
|
||||
assert model.repo, f"{model.name} has no repo"
|
||||
assert model.onnx_file, f"{model.name} has no onnx_file"
|
||||
|
||||
def test_openai_model_needs_no_local_artifacts(self):
|
||||
assert reg.OPENAI_ADA_002.provider == "openai"
|
||||
assert reg.OPENAI_ADA_002.repo is None
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_dimension_for_known_and_unknown(self):
|
||||
assert reg.dimension_for(reg.GRANITE_97M.name) == 384
|
||||
assert reg.dimension_for("nope/nope") is None
|
||||
|
||||
def test_max_input_tokens_for_known_and_unknown(self):
|
||||
assert reg.max_input_tokens_for("granite-311m") == 32768
|
||||
assert reg.max_input_tokens_for("nope/nope") is None
|
||||
|
||||
def test_known_names_lists_canonical_spellings(self):
|
||||
names = reg.known_names()
|
||||
assert reg.MPNET.name in names
|
||||
assert reg.GRANITE_311M.name in names
|
||||
|
||||
def test_defaults_point_at_the_intended_models(self):
|
||||
"""Existing installs stay on mpnet; new installs get granite."""
|
||||
assert reg.DEFAULT_LEGACY == reg.MPNET.name
|
||||
assert reg.DEFAULT_NEW_INSTALL == reg.GRANITE_311M.name
|
||||
|
||||
def test_no_alias_collisions_between_models(self):
|
||||
seen = {}
|
||||
for model in reg.MODELS:
|
||||
for key in (model.name, *model.aliases):
|
||||
assert key.lower() not in seen, f"{key} claimed twice"
|
||||
seen[key.lower()] = model
|
||||
@@ -83,3 +83,48 @@ def test_query_path_is_truncated(monkeypatch):
|
||||
|
||||
sent = captured["payload"]["input"]
|
||||
assert sent == enc.decode(enc.encode(long_text)[:10])
|
||||
|
||||
|
||||
class TestInputLimitResolution:
|
||||
"""The cap falls back to the model's own context window."""
|
||||
|
||||
def _remote(self, model_name):
|
||||
from application.vectorstore.base import RemoteEmbeddings
|
||||
|
||||
return RemoteEmbeddings(
|
||||
api_url="http://embeddings", model_name=model_name, api_key=None
|
||||
)
|
||||
|
||||
def test_explicit_setting_wins(self, monkeypatch):
|
||||
from application.vectorstore import base
|
||||
|
||||
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 123)
|
||||
assert self._remote("granite-311m")._resolve_input_limit() == 123
|
||||
|
||||
def test_registered_model_supplies_its_own_ceiling(self, monkeypatch):
|
||||
from application.vectorstore import base
|
||||
|
||||
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
|
||||
assert self._remote("granite-311m")._resolve_input_limit() == 32768
|
||||
# mpnet genuinely stops at 384; sending more is paid for and discarded.
|
||||
assert self._remote("all-mpnet-base-v2")._resolve_input_limit() == 384
|
||||
|
||||
def test_unknown_model_stays_unlimited(self, monkeypatch):
|
||||
from application.vectorstore import base
|
||||
|
||||
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
|
||||
assert self._remote("some-org/mystery")._resolve_input_limit() is None
|
||||
|
||||
def test_non_positive_setting_falls_through_to_the_registry(self, monkeypatch):
|
||||
from application.vectorstore import base
|
||||
|
||||
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 0)
|
||||
assert self._remote("granite-97m")._resolve_input_limit() == 32768
|
||||
|
||||
def test_dimension_is_taken_from_the_registry(self):
|
||||
assert self._remote("granite-97m").dimension == 384
|
||||
assert self._remote("granite-311m").dimension == 768
|
||||
|
||||
def test_unknown_model_dimension_is_probed_not_assumed(self):
|
||||
"""The old hardcoded 768 made the probe below unreachable."""
|
||||
assert self._remote("some-org/mystery").dimension is None
|
||||
Reference in new issue
Block a user