AI_Menu_Search / tests /test_embedding_cache.py
Juhaha
Claude Opus 5.5 (1M context)
feat: keyword partial match + embedding cache + candidate injection (v1.5.0 + v1.5.1)
5ceb76e
Raw History Blame Contribute Delete
5.19 kB
"""검색 질의 임베딩 캐시.
2026-09-23 운영 부하테스트에서 GPU 코어 85%, 메모리 복사율 40~50%가 관측됐다.
질의가 짧아 연산보다 CPU↔GPU 복사가 지배적인데, 같은 질의도 매번 GPU로
보내고 있었다. 검색 경로(embed_text)만 캐시하고 색인 경로(embed_batch)는
건드리지 않는다.
"""
import threading
from collections import OrderedDict
import numpy as np
import pytest
from core.embedder import MenuEmbedder
def make_embedder(capacity=2048, dim=8):
"""모델 로드 없이 캐시 동작만 검증하는 인스턴스."""
e = MenuEmbedder.__new__(MenuEmbedder)
e.backend = "test"
e.model = None
e._client = None
e._cache_lock = threading.Lock()
e._cache = OrderedDict() if capacity > 0 else None
e._cache_hits = 0
e._cache_misses = 0
e.calls = []
def _one(text):
e.calls.append(text)
# float32로 정확히 표현되지 않는 값을 쓴다. 6.0 같은 값이면 적중·미스
# 정밀도 차이를 테스트가 못 잡는다(실제로 그 버그가 있었다).
base = 0.123456789012345 + len(text) * 0.0001
return [base + i * 1e-7 for i in range(dim)]
e._embed_one = _one
return e
def test_repeated_query_hits_the_cache(monkeypatch):
e = make_embedder()
for q in ["이체", "이체", "애플 차트", "이체", "애플 차트"]:
e.embed_text(q)
assert e.calls == ["이체", "애플 차트"], "같은 질의는 한 번만 임베딩"
stats = e.cache_stats()
assert (stats["hits"], stats["misses"]) == (3, 2)
assert stats["hitRatePct"] == 60.0
def test_miss_and_hit_return_bitwise_identical_vectors():
"""재현성: 첫 호출(미스)과 둘째 호출(적중)이 완전히 같은 값이어야 한다.
미스만 float64 원본을 돌려주면 같은 질의의 dense 점수가 캐시 상태에 따라
~1e-7 흔들려 v1.4.7의 응답 재현성 보장이 깨진다.
"""
e = make_embedder(dim=1024)
first = e.embed_text("삼성전자 차트") # 미스
second = e.embed_text("삼성전자 차트") # 적중
third = e.embed_text("삼성전자 차트") # 적중
assert first == second == third
assert isinstance(second, list) and all(isinstance(v, float) for v in second)
def test_returned_values_are_float32_representable():
"""모든 경로가 float32를 거치므로 float32로 왕복해도 값이 변하지 않아야 한다."""
e = make_embedder(dim=64)
for vector in (e.embed_text("이체"), e.embed_text("이체")):
roundtrip = np.asarray(vector, dtype=np.float32).tolist()
assert roundtrip == vector
def test_disabled_cache_keeps_raw_backend_output():
"""캐시를 꺼두면 float32 반올림 없이 백엔드 원본을 그대로 돌려준다."""
e = make_embedder(capacity=0)
raw = e._embed_one("이체")
assert e.embed_text("이체") == raw
def test_distinct_queries_are_not_confused():
e = make_embedder()
a = e.embed_text("이체")
b = e.embed_text("이체내역")
assert a != b
assert e.embed_text("이체") == a
assert e.embed_text("이체내역") == b
def test_lru_evicts_the_oldest_entry(monkeypatch):
monkeypatch.setattr("core.embedder.EMBEDDING_CACHE_SIZE", 2)
e = make_embedder(capacity=2)
e.embed_text("a")
e.embed_text("b")
e.embed_text("a") # a를 최근으로 끌어올림
e.embed_text("c") # 가장 오래된 b가 밀려남
assert list(e._cache.keys()) == ["a", "c"]
e.embed_text("b") # 다시 임베딩되어야 함
assert e.calls == ["a", "b", "c", "b"]
def test_cache_can_be_disabled(monkeypatch):
monkeypatch.setattr("core.embedder.EMBEDDING_CACHE_SIZE", 0)
e = make_embedder(capacity=0)
e.embed_text("이체")
e.embed_text("이체")
assert e.calls == ["이체", "이체"], "꺼두면 매번 임베딩"
assert e.cache_stats() == {"enabled": False}
def test_vectors_are_stored_as_float32():
"""파이썬 float 리스트로 두면 1024차원이 약 32KB로 8배 든다."""
e = make_embedder(dim=1024)
e.embed_text("이체")
stored = e._cache["이체"]
assert isinstance(stored, np.ndarray)
assert stored.dtype == np.float32
assert stored.nbytes == 1024 * 4
def test_concurrent_access_is_consistent():
e = make_embedder()
results = []
def worker():
for _ in range(50):
results.append(tuple(e.embed_text("동시성")))
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
assert len(set(results)) == 1, "스레드마다 다른 값이 나오면 안 된다"
assert len(results) == 400
# 락 밖에서 임베딩하므로 초기에 중복 호출이 날 수 있으나 소수여야 한다
assert len(e.calls) <= 8
def test_batch_path_is_not_cached():
"""색인은 embed_batch를 쓴다. 캐시에 얹으면 메모리가 터진다."""
e = make_embedder()
assert not hasattr(MenuEmbedder.embed_batch, "cache_info")
e.embed_text("이체")
assert list(e._cache.keys()) == ["이체"]