Spaces:
Running
Running
Juhaha
Claude Opus 5.5 (1M context)
feat: keyword partial match + embedding cache + candidate injection (v1.5.0 + v1.5.1)
5ceb76e Download tests/test_embedding_cache.py from juhaha1230/AI_Menu_Search: direct link, hf CLI and curl.
- Browser
- Download file 5.19 kB
-
https://huggingface.co/spaces/juhaha1230/AI_Menu_Search/resolve/main/tests/test_embedding_cache.py
- Command line
-
hf download hf://spaces/juhaha1230/AI_Menu_Search/tests/test_embedding_cache.py
-
curl -L -o test_embedding_cache.py https://huggingface.co/spaces/juhaha1230/AI_Menu_Search/resolve/main/tests/test_embedding_cache.py
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()) == ["이체"] | |