Files
mlc-ai--mlc-llm/tests/python/serve/test_embedding_engine.py
T
wehub-resource-sync 770d92cb1f
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:23:58 +08:00

366 lines
14 KiB
Python

"""Embedding engine tests in MLC LLM.
Tests AsyncEmbeddingEngine for both direct (sync) and async embedding inference.
Reuses MLC LLM test infrastructure: markers, require_test_model pattern,
and conventions from test_serve_engine.py.
Run with real model (requires GPU + compiled embedding model):
MLC_SERVE_EMBEDDING_MODEL_LIB="path/to/model.dylib" \
pytest -m engine tests/python/serve/test_embedding_engine.py -v
Environment variables:
MLC_SERVE_EMBEDDING_MODEL_LIB Path to compiled embedding model library (required)
MLC_SERVE_EMBEDDING_MODEL Path to embedding model weight directory
(optional, defaults to dirname of model lib)
"""
import asyncio
import os
import numpy as np
import pytest
# Reuse MLC LLM marker system (registered in tests/python/conftest.py)
pytestmark = [pytest.mark.engine]
# ---------------------------------------------------------------------------
# Fixtures — follows pattern from serve/server/conftest.py (served_model)
# ---------------------------------------------------------------------------
EMBEDDING_MODEL_LIB = os.environ.get("MLC_SERVE_EMBEDDING_MODEL_LIB")
EMBEDDING_MODEL_DIR = os.environ.get(
"MLC_SERVE_EMBEDDING_MODEL",
os.path.dirname(EMBEDDING_MODEL_LIB) if EMBEDDING_MODEL_LIB else None,
)
def _skip_if_no_model():
if EMBEDDING_MODEL_LIB is None:
pytest.skip(
'Environment variable "MLC_SERVE_EMBEDDING_MODEL_LIB" not found. '
"Set it to a compiled embedding model library "
"(e.g., Qwen3-Embedding-0.6B-q0f32-MLC.dylib)."
)
if not os.path.isfile(EMBEDDING_MODEL_LIB):
pytest.skip(f"Embedding model library not found at: {EMBEDDING_MODEL_LIB}")
if EMBEDDING_MODEL_DIR is None or not os.path.isdir(EMBEDDING_MODEL_DIR):
pytest.skip(f"Embedding model directory not found at: {EMBEDDING_MODEL_DIR}")
@pytest.fixture(scope="module")
def embedding_engine():
"""Module-scoped AsyncEmbeddingEngine — loaded once, shared across tests."""
_skip_if_no_model()
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
engine = AsyncEmbeddingEngine(
model=EMBEDDING_MODEL_DIR,
model_lib=EMBEDDING_MODEL_LIB,
device="auto",
)
yield engine
engine.terminate()
# ---------------------------------------------------------------------------
# Helpers — reuse cosine_similarity pattern from test_serve_engine.py
# ---------------------------------------------------------------------------
def cosine_similarity(a, b):
"""Return cosine similarity between two vectors."""
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
# ===================================================================
# Engine initialization tests
# ===================================================================
def test_engine_model_type(embedding_engine):
"""Engine reports a valid model type."""
assert embedding_engine.model_type in ("encoder", "decoder")
def test_engine_pooling_strategy(embedding_engine):
"""Engine selects appropriate default pooling strategy."""
if embedding_engine.model_type == "encoder":
assert embedding_engine.pooling_strategy == "cls"
else:
assert embedding_engine.pooling_strategy == "last"
# ===================================================================
# Single-text embedding
# ===================================================================
def test_single_text_shape(embedding_engine):
"""Single text returns exactly one embedding vector."""
embeddings, tokens = embedding_engine.embed(["Hello world"])
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
def test_single_text_unit_norm(embedding_engine):
"""Embedding output is L2-normalized."""
embeddings, _ = embedding_engine.embed(["Hello world"])
norm = float(np.linalg.norm(embeddings[0]))
assert abs(norm - 1.0) < 1e-4, f"Expected unit norm, got {norm}"
# ===================================================================
# Batch embedding
# ===================================================================
BATCH_TEXTS = [
"Machine learning is fascinating",
"I love pizza",
"Deep learning uses neural networks",
]
def test_batch_count(embedding_engine):
"""Batch embedding returns one vector per input."""
embeddings, tokens = embedding_engine.embed(BATCH_TEXTS)
assert len(embeddings) == len(BATCH_TEXTS)
assert tokens > 0
def test_batch_all_normalized(embedding_engine):
"""Every vector in a batch is L2-normalized."""
embeddings, _ = embedding_engine.embed(BATCH_TEXTS)
for i, emb in enumerate(embeddings):
norm = float(np.linalg.norm(emb))
assert abs(norm - 1.0) < 1e-4, f"Embedding [{i}] norm={norm}"
def test_batch_consistent_dimension(embedding_engine):
"""All embeddings in a batch have the same dimension."""
embeddings, _ = embedding_engine.embed(BATCH_TEXTS)
dims = {len(emb) for emb in embeddings}
assert len(dims) == 1, f"Inconsistent dimensions: {dims}"
# ===================================================================
# Semantic quality — cosine similarity ranking
# ===================================================================
SIMILARITY_TEXTS = [
"What is machine learning?",
"Explain deep learning algorithms",
"I want to order pizza",
]
def test_cosine_similarity_ranking(embedding_engine):
"""Related texts have higher cosine similarity than unrelated texts."""
embeddings, _ = embedding_engine.embed(SIMILARITY_TEXTS)
e_ml, e_dl, e_pizza = [np.array(e) for e in embeddings]
sim_related = float(np.dot(e_ml, e_dl))
sim_unrelated = float(np.dot(e_ml, e_pizza))
assert sim_related > sim_unrelated, (
f"Related sim ({sim_related:.4f}) should > unrelated sim ({sim_unrelated:.4f})"
)
# ===================================================================
# Determinism
# ===================================================================
def test_deterministic_output(embedding_engine):
"""Same input produces identical output across calls."""
text = ["Deterministic test"]
emb1, _ = embedding_engine.embed(text)
emb2, _ = embedding_engine.embed(text)
cos = cosine_similarity(emb1[0], emb2[0])
assert cos > 0.9999, f"Expected deterministic output, cosine={cos}"
# ===================================================================
# Async embedding
# ===================================================================
def test_async_embed(embedding_engine):
"""async_embed produces same result as sync embed."""
text = ["Async test"]
sync_emb, sync_tokens = embedding_engine.embed(text)
loop = asyncio.new_event_loop()
try:
async_emb, async_tokens = loop.run_until_complete(embedding_engine.async_embed(text))
finally:
loop.close()
assert sync_tokens == async_tokens
cos = cosine_similarity(sync_emb[0], async_emb[0])
assert cos > 0.9999, f"Async vs sync mismatch, cosine={cos}"
# ===================================================================
# Edge cases
# ===================================================================
def test_empty_string(embedding_engine):
"""Empty string should still produce a valid embedding for supported models."""
embeddings, tokens = embedding_engine.embed([""])
if embedding_engine.model_type == "encoder":
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
else:
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
# ===================================================================
# Long text handling (model-type dependent)
# ===================================================================
def test_long_text_decoder_chunked_prefill(embedding_engine):
"""[Decoder only] Text >prefill_chunk_size triggers chunked prefill.
~5000 tokens processed in 3 chunks. Result is unit-norm embedding."""
if embedding_engine.model_type != "decoder":
pytest.skip("Chunked prefill is decoder-only")
long_text = "word " * 5000
embeddings, tokens = embedding_engine.embed([long_text])
assert tokens > 2048, f"Expected >2048 tokens to trigger chunking, got {tokens}"
norm = float(np.linalg.norm(embeddings[0]))
assert abs(norm - 1.0) < 1e-3
def _get_encoder_tokens(embedding_engine, text):
"""Replicate encoder preprocessing: tokenize and add [CLS]/[SEP]."""
tokens = list(embedding_engine.tokenizer.encode(text))
if embedding_engine._cls_token_id is not None and (
len(tokens) == 0 or tokens[0] != embedding_engine._cls_token_id
):
tokens = [embedding_engine._cls_token_id, *tokens]
if embedding_engine._sep_token_id is not None and (
len(tokens) == 0 or tokens[-1] != embedding_engine._sep_token_id
):
tokens = [*tokens, embedding_engine._sep_token_id]
return tokens
def test_long_text_encoder_truncation(embedding_engine):
"""[Encoder only] Text exceeding prefill_chunk_size is truncated.
Two texts with the same shared prefix but different suffixes beyond the
limit should produce identical embeddings, since the suffix is truncated
and the retained token prefixes are verified to be identical."""
if embedding_engine.model_type != "encoder":
pytest.skip("Truncation test is encoder-only")
prefill_chunk = embedding_engine._metadata.get("prefill_chunk_size", 512)
# Dynamically construct input that exceeds prefill_chunk_size.
unit = "machine learning is great "
suffix_a = " alpha beta gamma " * 200
suffix_b = " totally different ending " * 200
unit_tokens = len(list(embedding_engine.tokenizer.encode(unit)))
repeats = max(1, prefill_chunk // max(unit_tokens, 1) + 64)
# Increase prefix length until both inputs exceed prefill_chunk_size
# and their truncated token prefixes are identical.
while True:
shared_prefix = unit * repeats
full_tokens_a = _get_encoder_tokens(embedding_engine, shared_prefix + suffix_a)
full_tokens_b = _get_encoder_tokens(embedding_engine, shared_prefix + suffix_b)
if (
len(full_tokens_a) > prefill_chunk
and len(full_tokens_b) > prefill_chunk
and full_tokens_a[:prefill_chunk] == full_tokens_b[:prefill_chunk]
):
break
repeats += 64
assert repeats < 200000, "Failed to construct truncation test inputs"
text_a = shared_prefix + suffix_a
text_b = shared_prefix + suffix_b
emb_a, tokens_a = embedding_engine.embed([text_a])
emb_b, tokens_b = embedding_engine.embed([text_b])
# Verify truncation happened
assert tokens_a <= prefill_chunk, (
f"Encoder should truncate to {prefill_chunk}, got {tokens_a} tokens"
)
assert tokens_b <= prefill_chunk
# Both should be valid unit-norm embeddings
assert abs(float(np.linalg.norm(emb_a[0])) - 1.0) < 1e-3
assert abs(float(np.linalg.norm(emb_b[0])) - 1.0) < 1e-3
# Both truncated to identical token sequences → embeddings must match
cos = cosine_similarity(emb_a[0], emb_b[0])
assert cos > 0.999, f"Same truncated tokens should match, cosine={cos:.6f}"
def test_long_vs_short_semantic_quality(embedding_engine):
"""Long text should still capture semantic meaning correctly.
Decoder: chunked prefill preserves full context.
Encoder: truncation keeps most relevant prefix."""
short_ml = "Machine learning enables systems to learn from data"
long_ml = (
"Machine learning is a fascinating field of study. " * 200
+ "It enables systems to learn from data."
)
pizza = "I want to order a pepperoni pizza for dinner"
embs, _ = embedding_engine.embed([short_ml, long_ml, pizza])
e_short, e_long, e_pizza = [np.array(e) for e in embs]
sim_same_topic = float(np.dot(e_short, e_long))
sim_different = float(np.dot(e_short, e_pizza))
assert sim_same_topic > sim_different, (
f"Same topic ({sim_same_topic:.4f}) should > different ({sim_different:.4f})"
)
def test_unicode_text(embedding_engine):
"""Unicode input is handled correctly."""
texts = ["Привет мир", "你好世界", "こんにちは世界"]
embeddings, _ = embedding_engine.embed(texts)
assert len(embeddings) == 3
for emb in embeddings:
assert abs(float(np.linalg.norm(emb)) - 1.0) < 1e-4
# ===================================================================
# Standalone runner (like test_serve_engine.py)
# ===================================================================
if __name__ == "__main__":
_skip_if_no_model()
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
engine = AsyncEmbeddingEngine(
model=EMBEDDING_MODEL_DIR,
model_lib=EMBEDDING_MODEL_LIB,
device="auto",
)
try:
test_engine_model_type(engine)
test_engine_pooling_strategy(engine)
test_single_text_shape(engine)
test_single_text_unit_norm(engine)
test_batch_count(engine)
test_batch_all_normalized(engine)
test_batch_consistent_dimension(engine)
test_cosine_similarity_ranking(engine)
test_deterministic_output(engine)
test_async_embed(engine)
test_empty_string(engine)
test_long_text_decoder_chunked_prefill(engine)
test_long_text_encoder_truncation(engine)
test_long_vs_short_semantic_quality(engine)
test_unicode_text(engine)
print("\nAll embedding engine tests passed!")
finally:
engine.terminate()