fix(lps): AI 매칭이 후보 많으면 통째로 무너지던 문제 — 배치 분할 + 규칙 충돌 제거

A/B 실험(상품 8개)에서 2개가 매칭 0~1건으로 사실상 실패했다. 수집은 40건 정상, 가격도
정확했는데 판정이 못 찾았다. 파고드니 두 가지였다.

**① 일괄 판정 붕괴(핵심)**: 후보 37건을 한 번에 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를
매기고 **전부 불일치**로 답한다. temperature=0 에서 3회 재현. 10건씩 쪼개면 같은 모델·같은
입력으로 12건이 매칭된다. 상품 3종에서 모두 재현(크리넥스 0→12, 다우니 8→23).
→ _BATCH=10 으로 쪼개 병렬 판정 후 전체 index 로 복원. 지연은 배치 1개분, 토큰은 합산.

**② 프롬프트 규칙 충돌**: "포장(개수/박스) 차이는 동일 상품" vs "용량·규격이 다르면 불일치" 가
함께 있어 '30롤 1팩'과 '30롤 2팩'(=60롤)을 어느 쪽으로 볼지 정할 수 없었다. 최저가 관점에선
수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로 쓰면 왜곡). 종류 혼동도 실측에서
오탐을 만들었다 — '프라이팬'을 찾는데 볶음팬·웍팬이 매칭됐다.
→ 규칙을 '무시할 차이 / 불일치로 볼 차이'로 갈라 재작성(수량·종류 명시).

결과(캐시 후보 재판정):
  크리넥스 30롤   매칭 0 → 5건, 최저가 **not_found → 15,500원**
  다우니 4.1L    매칭 8 → 12건, 최저 21,890 유지
  테팔 프라이팬   매칭 2 → 0건 (볶음팬·웍팬 오탐 제거 — 후보에 진짜 프라이팬이 없었다)

테스트 6건 추가(배치 분할 시 index 매핑·누락 보수처리·토큰 합산·빈 후보·상수 상한·규칙 충돌),
전체 237 passed.
This commit is contained in:
민헌 2026-08-05 15:45:32 +09:00
parent 8574df4c16
commit b2b5708a6a
2 changed files with 141 additions and 14 deletions

View File

@ -5,6 +5,9 @@ LLM 판단으로 대체 — 액세서리/호환부품/다른 상품/명백히
structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다. structured output(Pydantic)으로 정규식 파싱 없이 안정적으로 결과를 받는다.
""" """
import asyncio
from dataclasses import dataclass
from openai import AsyncOpenAI from openai import AsyncOpenAI
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -12,16 +15,35 @@ from common.logger import LOG
from config.server_configs import openai_config from config.server_configs import openai_config
from services.search.contract import NormalizedProduct from services.search.contract import NormalizedProduct
# 규칙은 '무시할 차이'와 '불일치로 볼 차이'로 갈라 쓴다. 예전엔 "포장(개수/박스) 차이는 동일"과
# "용량·규격이 다르면 불일치"가 함께 있어 '30롤 1팩' vs '30롤 2팩'(=60롤)을 어느 쪽으로 볼지
# 모델이 정할 수 없었다. 최저가 관점에선 수량이 다르면 다른 상품이다(60롤 값을 30롤 최저가로
# 쓰면 왜곡). 종류 혼동(프라이팬/볶음팬/웍팬)도 실측에서 오탐을 만들어 명시한다.
_SYSTEM = ( _SYSTEM = (
"너는 최저가 비교 시스템의 상품 매칭기다. 검색 결과 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\n" "너는 최저가 비교 시스템의 상품 매칭기다. 후보가 '찾는 상품과 동일한 상품'인지 판별하라.\n"
"규칙:\n" "동일하다고 보는 차이(무시할 것):\n"
"- 액세서리·호환부품·부속품(빨대마개·뚜껑·커버·거치대·스트랩·보호필름 등)은 불일치(false).\n" "- 판매자·스토어, 색상/향, 사은품·증정품, 배송 문구, 상품명 수식어(무형광·프리미엄 등)\n"
"- 다른 종류/브랜드/모델은 불일치. 모델명이 주어지면 모델 일치를 우선한다.\n" "불일치로 보는 차이:\n"
"- 용량·규격이 명백히 다르면 불일치.\n" "- 종류가 다름: 프라이팬 / 볶음팬 / 웍팬 / 냄비는 서로 다른 종류다. 세트 구성품도 단품과 다르다\n"
"- 판매자·색상·포장(개수/박스)·사은품 차이는 동일 상품으로 본다.\n" "- 브랜드·모델이 다름(모델명이 주어지면 모델 일치를 우선)\n"
"- 확신이 낮으면 score 를 낮게 준다." "- 용량·크기가 다름(ml·L·g·cm)\n"
"- 수량이 다름: 개입·롤수·팩수. 예) '30롤 1팩'과 '30롤 2팩'(=60롤)은 다른 상품이다\n"
"- 액세서리·호환부품(뚜껑·손잡이·거치대·리필 전용 부속 등)\n"
"확신이 낮으면 score 를 낮게 준다."
) )
# 한 번에 판정할 후보 수. **크면 판정이 통째로 무너진다** — 실측(2026-08-05): 후보 37건을 일괄로
# 넣으면 gpt-4o-mini 가 전 항목에 같은 점수(70)를 매기고 전부 불일치로 답한다(3회 재현).
# 10건씩 나누면 같은 입력·같은 모델로 12건이 매칭됐다(0건 → 12건). 상품 3종에서 모두 재현.
_BATCH = 10
@dataclass
class _Usage:
"""배치 여러 개의 토큰을 합쳐 계측부(metrics.add_ai)에 하나로 넘긴다."""
prompt_tokens: int = 0
completion_tokens: int = 0
class Judgment(BaseModel): class Judgment(BaseModel):
index: int = Field(description="후보 번호(1부터)") index: int = Field(description="후보 번호(1부터)")
@ -40,11 +62,34 @@ class SimilarityJudge:
self.last_usage = None # 직전 호출 토큰 usage(계측용) — 호출부가 await 직후 읽는다 self.last_usage = None # 직전 호출 토큰 usage(계측용) — 호출부가 await 직후 읽는다
async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]: async def judge(self, target: dict, candidates: list[NormalizedProduct]) -> list[Judgment]:
"""후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환.""" """후보별 동일상품 여부 판정. candidates 와 같은 순서/길이로 Judgment 리스트 반환.
후보를 _BATCH 단위로 쪼개 **병렬**로 묻고 합친다(지연은 배치 1개분). 쪼개는 이유는
속도가 아니라 정확도다 — 위 _BATCH 주석의 실측 참고.
"""
self.last_usage = None self.last_usage = None
if not candidates: if not candidates:
return [] return []
chunks = [candidates[i:i + _BATCH] for i in range(0, len(candidates), _BATCH)]
results = await asyncio.gather(*(self._judge_batch(target, ch) for ch in chunks))
out: list[Judgment] = []
prompt = completion = 0
for offset, (judgments, usage) in zip(range(0, len(candidates), _BATCH), results):
for k in range(len(chunks[offset // _BATCH])):
j = judgments.get(k + 1)
# 배치 안 번호(1..n)를 전체 번호로 되돌린다. 누락은 보수적으로 불일치.
out.append(Judgment(index=offset + k + 1,
is_match=bool(j and j.is_match), score=j.score if j else 0))
prompt += getattr(usage, "prompt_tokens", 0) or 0
completion += getattr(usage, "completion_tokens", 0) or 0
self.last_usage = _Usage(prompt, completion)
LOG.d(f"[ai] 판정 {len(candidates)}건({len(chunks)}배치) 중 매칭 {sum(1 for j in out if j.is_match)}건")
return out
async def _judge_batch(self, target: dict, candidates: list[NormalizedProduct]):
"""배치 1개 판정 → ({배치내 index: Judgment}, usage)."""
lines = "\n".join(f"{i + 1}. {c.name} ({c.price}원)" for i, c in enumerate(candidates)) lines = "\n".join(f"{i + 1}. {c.name} ({c.price}원)" for i, c in enumerate(candidates))
user = ( user = (
f"[찾는 상품]\n" f"[찾는 상품]\n"
@ -62,10 +107,5 @@ class SimilarityJudge:
response_format=JudgmentList, response_format=JudgmentList,
temperature=0, temperature=0,
) )
self.last_usage = getattr(resp, "usage", None)
parsed = resp.choices[0].message.parsed parsed = resp.choices[0].message.parsed
by_idx = {j.index: j for j in (parsed.judgments if parsed else [])} return {j.index: j for j in (parsed.judgments if parsed else [])}, getattr(resp, "usage", None)
# 누락된 후보는 보수적으로 불일치 처리
out = [by_idx.get(i + 1, Judgment(index=i + 1, is_match=False, score=0)) for i in range(len(candidates))]
LOG.d(f"[ai] 판정 {len(candidates)}건 중 매칭 {sum(1 for j in out if j.is_match)}건")
return out

View File

@ -0,0 +1,87 @@
"""AI 동일상품 판정의 **오케스트레이션** 테스트 (모델 호출은 대역).
모델 응답 품질이 아니라 계약을 지킨다:
- 후보를 배치로 쪼개도 판정 개수·순서가 보존되는가(전체 index 매핑)
- 배치가 누락 응답을 줘도 보수적으로 불일치 처리되는가
- 토큰 사용량이 배치 전체로 합산되는가(원가 계측이 배치 수만큼 새면 안 됨)
배치로 쪼개는 이유는 속도가 아니라 정확도다 — 후보 37건을 일괄로 넣으면 gpt-4o-mini 가
전 항목에 같은 점수를 매기고 전부 불일치로 답한다(2026-08-05 실측, 3회 재현).
"""
import services.ai.similarity as sim
from services.ai.similarity import Judgment, SimilarityJudge
from services.search.contract import NormalizedProduct
def _cands(n):
return [NormalizedProduct(source="naver", name=f"상품{i}", price=1000 + i) for i in range(n)]
class _Usage:
def __init__(self, p, c):
self.prompt_tokens, self.completion_tokens = p, c
def _judge_with(monkeypatch, batch_impl):
judge = SimilarityJudge.__new__(SimilarityJudge) # __init__ 은 OpenAI 클라이언트를 만든다
judge._model = "test"
judge.last_usage = None
monkeypatch.setattr(judge, "_judge_batch", batch_impl, raising=False)
return judge
async def test_batches_preserve_order_and_global_index(monkeypatch):
seen = []
async def fake(target, part):
seen.append(len(part))
# 배치 안에서는 항상 1..n 로 번호가 매겨진다 — 전체 번호로 되돌리는 건 judge 의 책임
return {i + 1: Judgment(index=i + 1, is_match=(part[i].price % 2 == 0), score=50) for i in range(len(part))}, _Usage(10, 2)
judge = _judge_with(monkeypatch, fake)
cands = _cands(25)
out = await judge.judge({}, cands)
assert seen == [10, 10, 5], "10건씩 쪼개져야 한다"
assert [j.index for j in out] == list(range(1, 26)), "전체 index 가 1..N 로 복원돼야 한다"
assert [j.is_match for j in out] == [c.price % 2 == 0 for c in cands], "판정이 후보와 어긋나면 안 된다"
async def test_missing_judgment_is_treated_as_no_match(monkeypatch):
async def fake(target, part):
return {1: Judgment(index=1, is_match=True, score=90)}, _Usage(5, 1) # 나머지 누락
judge = _judge_with(monkeypatch, fake)
out = await judge.judge({}, _cands(3))
assert [j.is_match for j in out] == [True, False, False]
assert out[2].score == 0
async def test_usage_is_summed_across_batches(monkeypatch):
async def fake(target, part):
return {}, _Usage(100, 20)
judge = _judge_with(monkeypatch, fake)
await judge.judge({}, _cands(25)) # 3배치
assert judge.last_usage.prompt_tokens == 300
assert judge.last_usage.completion_tokens == 60
async def test_empty_candidates_short_circuits(monkeypatch):
async def fake(target, part):
raise AssertionError("후보가 없으면 모델을 부르면 안 된다")
judge = _judge_with(monkeypatch, fake)
assert await judge.judge({}, []) == []
def test_batch_size_is_small_enough_to_avoid_degenerate_output():
"""실측 근거: 37건 일괄 → 전멸(0건). 10건 → 12건 매칭. 이 상수가 커지면 그 실패가 돌아온다."""
assert sim._BATCH <= 15
def test_prompt_rules_do_not_conflict_on_quantity():
"""예전 프롬프트는 '포장(개수/박스) 차이는 동일'과 '규격 다르면 불일치'가 충돌했다."""
assert "수량이 다름" in sim._SYSTEM
assert "포장(개수/박스)·사은품 차이는 동일" not in sim._SYSTEM