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()) == ["이체"]