Spaces:
Running
Running
File size: 5,193 Bytes
5ceb76e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | """검색 질의 임베딩 캐시.
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()) == ["이체"]
|