"""검색 질의 임베딩 캐시. 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()) == ["이체"]