홈강좌LAYA 결정 모델 24편 중 21편째
LAYA 쇼핑 검색 5 — 이 일에 LAYA 가 맞나: 사전·정규식과 나란히 재 본 결론
17~20편에서 쇼핑 검색어를 필터로 바꾸는 모델을 만들고 지속 학습까지 했다. 완전 일치가 학습 전 0.014 에서 v2 0.510 까지 올랐다. 그런데 이 숫자가 좋은지는 다른 방법과 나란히 놓아야 안다. 이 편에서 모델 없이 사전과 정규식으로 만든 규칙 파서와 비교한다.
결론부터 말한다. 이 일은 규칙이 중심이고, LAYA 는 규칙이 못 찾은 칸을 메우는 자리가 맞다. LAYA 혼자로는 규칙보다 낮고 느렸다. 둘을 섞은 방식이 가장 높았다.
01 규칙 파서
규칙은 검색어에서 단어를 찾아 값으로 바꾼다. v1 은 처음 기획 때 적었을 단어만 쓴다. v2 는 20편과 같은 운영 기록(필터 칩을 고친 검색어)을 사람이 읽고 단어를 더했다고 본다. "검정 말고 갈색" 처럼 부정이 들어가면 "말고" 뒤를 먼저 본다.
"""규칙 파서 — 사전(단어 → 값)과 정규식으로 검색어를 필터로 바꾼다. 모델 없이.
WORDS_V1 은 처음 기획 때 적은 단어, WORDS_V2 는 운영 로그(필터를 고친 검색어)를 보고 더한 단어다.
"""
import re
WORDS_V1 = {
"item": {"롱패딩": "padding", "패딩": "padding", "코트": "coat", "니트": "knit", "스웨터": "knit",
"반팔티": "tshirt", "티셔츠": "tshirt", "청바지": "jeans", "슬랙스": "slacks", "운동화": "sneakers",
"스니커즈": "sneakers", "부츠": "boots", "백팩": "bag", "가방": "bag"},
"category": {"아우터": "top", "상의": "top", "바지": "bottom", "하의": "bottom", "신발": "shoes"},
"gender": {"남성": "men", "남자": "men", "여성": "women", "여자": "women"},
"color": {"갈색": "brown", "브라운": "brown", "검정": "black", "블랙": "black", "흰색": "white", "화이트": "white",
"회색": "gray", "그레이": "gray", "남색": "navy", "네이비": "navy", "베이지": "beige"},
"season": {"겨울": True, "여름": False},
}
WORDS_V2 = { # 운영 로그에서 더한 단어
"item": {"숏패딩": "padding", "푸퍼": "padding", "니트티": "knit", "반팔": "tshirt", "긴팔티": "tshirt",
"데님 팬츠": "jeans", "정장 바지": "slacks", "러닝화": "sneakers", "워커": "boots",
"크로스백": "bag", "숄더백": "bag"},
"category": {"겉옷": "top", "팬츠": "bottom", "슈즈": "shoes"},
"gender": {"남친": "men", "남편": "men", "아빠": "men", "여친": "women", "아내": "women", "엄마": "women"},
"color": {"초코": "brown", "검은": "black", "하얀": "white", "차콜": "gray", "곤색": "navy", "샌드": "beige"},
"season": {"따뜻한": True, "방한": True, "시원한": False},
}
CATEGORY_OF = {"padding": "top", "coat": "top", "knit": "top", "tshirt": "top", "jeans": "bottom",
"slacks": "bottom", "sneakers": "shoes", "boots": "shoes", "bag": "bag"}
WINTER_ITEMS = {"padding", "coat", "knit", "boots"}
PRICE_V1 = re.compile(r"(\d+)\s*만\s*원?\s*(?:이하|미만|안쪽)")
PRICE_V2 = re.compile(r"(\d+)\s*만?\s*원?\s*(?:이하|미만|안쪽|아래|까지)|~\s*(\d+)\s*만")
def merged(version):
words = {k: dict(v) for k, v in WORDS_V1.items()}
if version == 2:
for k, v in WORDS_V2.items():
words[k].update(v)
return words
def lookup(text, table):
"""긴 단어부터 찾는다("롱패딩" 이 "패딩" 보다 먼저). 띄어쓰기는 빼고도 본다. 못 찾으면 None."""
for word in sorted(table, key=len, reverse=True):
if word in text or word.replace(" ", "") in text.replace(" ", ""):
return table[word]
return None
def focus(text):
"""v2: "검정 말고 갈색" · "갈색 바지에 어울리는 니트" — 찾는 것은 마지막 "말고"·"어울리는" 뒤에 있다."""
for mark in ("말고", "어울리는"):
if mark in text:
text = text.rsplit(mark, 1)[1]
return text
def parse(text, version=1):
"""필터 값 dict 와, 규칙이 실제로 근거를 찾은 칸의 집합을 돌려준다."""
words = merged(version)
found, out = set(), {}
m = (PRICE_V2 if version == 2 else PRICE_V1).search(text)
if m:
n = int(m.group(1) or m.group(2)) if version == 2 else int(m.group(1))
out["price"] = next((f"under{b}" for b in (10, 30, 50, 100) if n <= b), "none")
found.add("price")
for key in ("item", "gender", "color"):
v = (lookup(focus(text), words[key]) if version == 2 and key != "gender" else None) or lookup(text, words[key])
if v:
out[key] = v
found.add(key)
category = CATEGORY_OF.get(out.get("item")) or lookup(focus(text) if version == 2 else text, words["category"])
if category:
out["category"] = category
found.add("category")
season = lookup(text, words["season"])
if out.get("item") in WINTER_ITEMS or season is True:
out["winter"] = True
found.add("winter")
elif season is False or "item" in found:
out["winter"] = False
found.add("winter")
defaults = {"price": "none", "category": "none", "item": "none", "winter": False, "gender": "unknown", "color": "none"}
return {**defaults, **out}, found02 나란히 재기
"""같은 평가 세트로 네 가지를 잰다.
rules vN 사전·정규식만(v1 = 처음 기획 때 단어, v2 = 운영 로그를 보고 더한 단어)
LAYA vN LAYA 의 답 그대로
LAYA vN + table 품목을 알면 분류·겨울옷은 표(CATEGORY_OF·WINTER_ITEMS)로 정한다
rules + LAYA 규칙이 근거를 찾은 칸은 규칙, 못 찾은 칸은 "LAYA + table"
python compare.py checkpoints/shop-v1 checkpoints/shop-v2
"""
import json
import sys
import time
import laya
from rules import CATEGORY_OF, WINTER_ITEMS, parse
FIELDS = ["price", "category", "item", "winter", "gender", "color"]
cases = [json.loads(line) for line in open("shop-eval.jsonl", encoding="utf-8")]
results = {} # 이름 → (예측 목록, 검색어당 ms)
def laya_filters(agent, case):
a = agent.predict(case["state"], case["questions"])["answers"]
return {f: (a[f]["noul"] >= 0.5 if a[f]["type"] == "noul" else a[f]["choice"]) for f in FIELDS}
def with_table(p):
t = dict(p)
if p["item"] != "none":
t["category"] = CATEGORY_OF[p["item"]]
t["winter"] = p["winter"] or p["item"] in WINTER_ITEMS
return t
for version in (1, 2):
start = time.time()
results[f"rules v{version}"] = ([parse(c["state"], version)[0] for c in cases], (time.time() - start) / len(cases) * 1000)
for path in sys.argv[1:]:
v = int(path[-1])
agent = laya.load(path)
start = time.time()
lp = [laya_filters(agent, c) for c in cases]
ms = (time.time() - start) / len(cases) * 1000
tabled = [with_table(p) for p in lp]
mixed = []
for c, t in zip(cases, tabled):
r, found = parse(c["state"], v)
mixed.append({f: (r[f] if f in found else t[f]) for f in FIELDS})
results[f"LAYA v{v}"] = (lp, ms)
results[f"LAYA v{v} + table"] = (tabled, ms)
results[f"rules v{v} + LAYA v{v}"] = (mixed, ms)
SUBSETS = {"train": lambda c: c["pool"] == "train", "ops": lambda c: c["pool"] == "ops",
"unseen": lambda c: c["pool"] == "unseen", "말고": lambda c: "말고" in c["state"],
"어울리는": lambda c: "어울리는" in c["state"]}
print("subset sizes:", " · ".join(f"{k} {sum(map(f, cases))}" for k, f in SUBSETS.items()))
print(f"{'method':<22}{'exact':>7}" + "".join(f"{k:>8}" for k in SUBSETS) + f"{'ms/query':>10}")
for name, (preds, ms) in results.items():
ok = [all(p[f] == c["expected"][f] for f in FIELDS) for c, p in zip(cases, preds)]
cells = ""
for f in SUBSETS.values():
sub = [o for c, o in zip(cases, ok) if f(c)]
cells += f"{sum(sub) / len(sub):>8.3f}"
print(f"{name:<22}{sum(ok) / len(ok):>7.3f}{cells}{ms:>10.2f}")python compare.py checkpoints/shop-v1 checkpoints/shop-v2subset sizes: train 70 · ops 70 · unseen 70 · 말고 26 · 어울리는 5
method exact train ops unseen 말고 어울리는 ms/query
rules v1 0.276 0.829 0.000 0.000 0.154 0.400 0.01
rules v2 0.619 0.886 0.971 0.000 0.615 0.600 0.02
LAYA v1 0.305 0.800 0.114 0.000 0.269 0.800 90.15
LAYA v1 + table 0.333 0.886 0.114 0.000 0.308 0.800 90.15
rules v1 + LAYA v1 0.310 0.814 0.114 0.000 0.115 0.400 90.15
LAYA v2 0.510 0.929 0.543 0.057 0.500 0.600 88.65
LAYA v2 + table 0.529 0.929 0.557 0.100 0.538 0.600 88.65
rules v2 + LAYA v2 0.652 0.914 0.957 0.086 0.654 0.600 88.65- 규칙 v2(0.619)가 LAYA v2(0.510)보다 높다. 운영 기록으로 단어를 더하자 ops 단어가 0.000 → 0.971 이 됐다. LAYA 는 같은 기록으로 0.543 이다.
- 표를 더하면 LAYA 가 조금 오른다(0.510 → 0.529). 품목을 알면 분류와 겨울옷은 판단이 아니라 조회다. "패딩이면 상의·겨울옷" 을 모델에 맡기면 품목은 맞히고 분류를 틀리는 일이 생긴다.
- 섞은 방식이 가장 높다(0.652). 규칙이 단어를 찾은 칸은 규칙을, 못 찾은 칸만 LAYA 를 쓴다. train 단어 묶음이 0.886 → 0.914 로 오른 것은, 오타("페딩")처럼 사전이 못 찾는 것을 LAYA 가 일부 메운 덕으로 보인다.
- unseen 단어는 어느 방법도 못 푼다(0.000 ~ 0.100). 사전에 없고 학습에도 없는 단어다.
- "말고" 가 든 검색어에서도 규칙 v2(0.615)가 LAYA v2(0.500)보다 높다. 부정을 모델이 알아서 배울 것이라 기대했는데, 규칙 한 줄(
focus)이 더 잘했다. "어울리는" 이 든 검색어는 5건뿐이라 판단하지 않는다.
03 이 비교에서 규칙이 유리한 점
공정하게 짚을 것이 있다. 이 합성 데이터는 18편의 단어 표로 만들었고, 규칙 v2 의 사전에는 그 표의 ops 단어가 그대로 들어 있다. 그래서 ops 단어 0.971 은 규칙에 유리한 숫자다. 실제 검색 로그에서는 사전에 넣을 단어를 사람이 찾아야 하고 놓치는 것도 생긴다.
반대로 LAYA 쪽 비용은 숫자에 잘 안 보인다. v2 는 사람이 단어를 적지 않았다. 사용자가 고친 칩만으로 학습했다.
04 속도와 유지 비용
| 규칙(사전·정규식) | LAYA | |
|---|---|---|
| 검색어 하나 | 0.02ms | 89ms(맥북 GPU, 질문 여섯 개) |
| 새 단어가 생기면 | 사람이 로그를 보고 사전에 넣는다 | 칩 고친 기록이 쌓이면 다시 학습·승격 |
| 틀린 이유 | 사전·코드를 보면 바로 안다 | 확신 숫자만 있고 이유는 안 보인다 |
| 처음 보는 단어 | 못 찾는다(값을 비운다) | 틀리면서도 자신 있을 수 있다(20편) |
| 필요한 것 | 코드 몇십 줄 | 학습 파이프라인·평가셋·서버(12~15편) |
검색은 사람이 한 글자 칠 때마다 부르기도 하는 기능이다. 89ms 는 혼자 쓰면 괜찮지만, 트래픽이 많으면 같은 검색어 결과를 캐시해 두는 것이 필수다. 인기 검색어가 전체의 큰 몫을 차지하는 경우가 많아 캐시가 잘 듣는다.
05 다른 방법들
이 편에서 잰 것은 규칙과 LAYA 뿐이다. 아래는 재지 않은 방법이다.
| 방법 | 맞는 곳 | 약점 |
|---|---|---|
| 검색엔진 동의어 사전·형태소 분석 | 이미 쓰는 검색엔진(OpenSearch·Elasticsearch 등)에 단어 짝을 넣는다. 오타는 퍼지 검색 | 사전을 사람이 관리한다 — 규칙과 같은 일 |
| LLM 에 JSON 으로 필터를 받기 | 처음 보는 단어("밤색 → 갈색", "언니 → 여성")를 언어 지식으로 푼다 | 호출당 수백 ms 이상, 비용, 검색어를 밖으로 보낸다 |
| 임베딩 최근접 | 과거 검색어 중 가장 비슷한 것의 필터를 빌린다 | 로그가 많아야 한다, 처음 보는 단어는 여전히 어렵다 |
| LAYA | 정해진 선택지 판단, 로컬에서, 고친 기록으로 자동 학습 | 본 단어만 잘한다, 처음 보는 단어에 과신 |
unseen 단어가 이 사례의 남은 구멍이다. 이 구멍은 실시간 검색보다 하루 한 번 새 검색어를 모아 LLM 에 사전 후보를 만들게 하고, 사람이 승인해 사전에 넣는 쪽이 맞아 보인다. 이 가설은 다음 편(22편)에서 실제로 쟀다. 사전이 커지자 LAYA 가 메우는 빈칸의 값도 커져서, 이 편의 결론이 조금 바뀐다.
06 결론 — 이 사례에 LAYA 는 유의미한가
혼자서는 아니다. 섞으면 그렇다.
- 필터 칸 대부분은 단어 하나가 값을 거의 바로 가리킨다(패딩 → padding, 브라운 → brown). 판단보다 조회에 가깝고, 조회는 사전이 더 정확하고 빠르다.
- 가격은 숫자 계산이다. 정규식으로 금액을 꺼내 구간을 정하는 편이 확실하다.
- LAYA 는 사전이 못 찾은 칸을 메울 때 쓸모가 있다. 오타, 사전에 아직 없는 표현, 단어만으로 정하기 어려운 칸이다. 그리고 칩을 고친 기록으로 사람 손 없이 따라잡는다.
이 사례라면 이렇게 짓는다.
- 가격은 정규식, 품목·색·성별은 사전, 분류·겨울옷은 품목 표로 정한다.
- 규칙이 못 찾은 칸만 LAYA 에 묻고, 확신 0.9 이상일 때만 필터로 건다(19편).
- 필터 결과가 0건이면 LAYA 가 건 필터를 빼고 다시 검색한다.
- 칩을 고친 기록은 두 곳에 쓴다. 사전에 넣을 단어 후보(사람이 승인)와 LAYA 재학습 데이터(16편의 순서)다.
문의 분류 사례는 달랐다. "결제하기를 누르면 화면이 하얘져요" 를 결제팀이 아니라 기술팀으로 보내려면 문장 전체를 읽어야 한다. 단어 사전으로는 풀기 어렵고, 그래서 LAYA 가 학습 전 0.550 에서 0.815 까지 의미 있게 올랐다. 답이 단어에 있으면 사전, 문장 전체에 있으면 LAYA. 두 사례를 나란히 놓고 얻은 기준이다.
07 실습
rules.py의WORDS_V2에 unseen 단어 몇 개("밤색"·"아들"·"터틀넥")를 넣고compare.py를 다시 돌린다. 사전 한 줄이 무엇을 바꾸는지 본다.compare.py의 섞는 규칙을 반대로 바꿔 본다(LAYA 확신이 0.9 이상이면 LAYA, 아니면 규칙). 어느 쪽이 나은지 본다.- 내 서비스의 판단 하나를 골라 "답이 단어에 있나, 문장 전체에 있나" 를 적어 본다. 그것이 사전과 모델 중 무엇부터 시도할지를 정한다.