hyunsu blog

홈강좌LAYA 결정 모델 24편 중 21편째

LAYA 쇼핑 검색 5 — 이 일에 LAYA 가 맞나: 사전·정규식과 나란히 재 본 결론

목차 · 7
  1. 01규칙 파서
  2. 02나란히 재기
  3. 03이 비교에서 규칙이 유리한 점
  4. 04속도와 유지 비용
  5. 05다른 방법들
  6. 06결론 — 이 사례에 LAYA 는 유의미한가
  7. 07실습

17~20편에서 쇼핑 검색어를 필터로 바꾸는 모델을 만들고 지속 학습까지 했다. 완전 일치가 학습 전 0.014 에서 v2 0.510 까지 올랐다. 그런데 이 숫자가 좋은지는 다른 방법과 나란히 놓아야 안다. 이 편에서 모델 없이 사전과 정규식으로 만든 규칙 파서와 비교한다.

결론부터 말한다. 이 일은 규칙이 중심이고, LAYA 는 규칙이 못 찾은 칸을 메우는 자리가 맞다. LAYA 혼자로는 규칙보다 낮고 느렸다. 둘을 섞은 방식이 가장 높았다.

01 규칙 파서

규칙은 검색어에서 단어를 찾아 값으로 바꾼다. v1 은 처음 기획 때 적었을 단어만 쓴다. v2 는 20편과 같은 운영 기록(필터 칩을 고친 검색어)을 사람이 읽고 단어를 더했다고 본다. "검정 말고 갈색" 처럼 부정이 들어가면 "말고" 뒤를 먼저 본다.

python
"""규칙 파서 — 사전(단어 → 값)과 정규식으로 검색어를 필터로 바꾼다. 모델 없이.

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}, found

02 나란히 재기

python
"""같은 평가 세트로 네 가지를 잰다.

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}")
bash
python compare.py checkpoints/shop-v1 checkpoints/shop-v2
text
subset 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
그림 1.방법별 완전 일치 — 검색어 210건
0.00.20.40.60.81.0규칙 v1규칙 v2LAYA v1LAYA v2LAYA v2 + 표규칙 v2 + LAYA v2규칙 v1: 0.276규칙 v2: 0.619LAYA v1: 0.305LAYA v2: 0.510LAYA v2 + 표: 0.529규칙 v2 + LAYA v2: 0.6520.2760.6190.3050.5100.5290.6520.00.20.40.60.81.0규칙 v1규칙 v2LAYA v1LAYA v2LAYA v2 + 표규칙 v2 + LAYA v2규칙 v1: 0.276규칙 v2: 0.619LAYA v1: 0.305LAYA v2: 0.510LAYA v2 + 표: 0.529규칙 v2 + LAYA v2: 0.6520.2760.6190.3050.5100.5290.652

v1 은 처음 단어·처음 150건, v2 는 운영 기록을 더한 판

숫자로 보기
항목완전 일치
규칙 v10.276
규칙 v20.619
LAYA v10.305
LAYA v20.510
LAYA v2 + 표0.529
규칙 v2 + LAYA v20.652
그림 2.검색어 묶음별 — 규칙 v2, LAYA v2, 둘을 섞은 방식
0.00.20.40.60.81.0train 단어ops 단어unseen 단어"말고" 든 검색어train 단어 · 규칙 v2: 0.886train 단어 · LAYA v2: 0.929train 단어 · 규칙 v2 + LAYA v2: 0.914ops 단어 · 규칙 v2: 0.971ops 단어 · LAYA v2: 0.543ops 단어 · 규칙 v2 + LAYA v2: 0.957unseen 단어 · 규칙 v2: 0.000unseen 단어 · LAYA v2: 0.057unseen 단어 · 규칙 v2 + LAYA v2: 0.086"말고" 든 검색어 · 규칙 v2: 0.615"말고" 든 검색어 · LAYA v2: 0.500"말고" 든 검색어 · 규칙 v2 + LAYA v2: 0.6540.00.20.40.60.81.0train 단어ops 단어unseen 단어"말고" 든 검색어train 단어 · 규칙 v2: 0.886train 단어 · LAYA v2: 0.929train 단어 · 규칙 v2 + LAYA v2: 0.914ops 단어 · 규칙 v2: 0.971ops 단어 · LAYA v2: 0.543ops 단어 · 규칙 v2 + LAYA v2: 0.957unseen 단어 · 규칙 v2: 0.000unseen 단어 · LAYA v2: 0.057unseen 단어 · 규칙 v2 + LAYA v2: 0.086"말고" 든 검색어 · 규칙 v2: 0.615"말고" 든 검색어 · LAYA v2: 0.500"말고" 든 검색어 · 규칙 v2 + LAYA v2: 0.6540.8860.9290.9140.9710.5430.9570.0000.0570.0860.6150.5000.654
  • 규칙 v2
  • LAYA v2
  • 규칙 v2 + LAYA v2

묶음마다 70건, "말고" 는 26건

숫자로 보기
항목규칙 v2LAYA v2규칙 v2 + LAYA v2
train 단어0.8860.9290.914
ops 단어0.9710.5430.957
unseen 단어0.0000.0570.086
"말고" 든 검색어0.6150.5000.654
  • 규칙 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.02ms89ms(맥북 GPU, 질문 여섯 개)
새 단어가 생기면사람이 로그를 보고 사전에 넣는다칩 고친 기록이 쌓이면 다시 학습·승격
틀린 이유사전·코드를 보면 바로 안다확신 숫자만 있고 이유는 안 보인다
처음 보는 단어못 찾는다(값을 비운다)틀리면서도 자신 있을 수 있다(20편)
필요한 것코드 몇십 줄학습 파이프라인·평가셋·서버(12~15편)

검색은 사람이 한 글자 칠 때마다 부르기도 하는 기능이다. 89ms 는 혼자 쓰면 괜찮지만, 트래픽이 많으면 같은 검색어 결과를 캐시해 두는 것이 필수다. 인기 검색어가 전체의 큰 몫을 차지하는 경우가 많아 캐시가 잘 듣는다.

05 다른 방법들

이 편에서 잰 것은 규칙과 LAYA 뿐이다. 아래는 재지 않은 방법이다.

방법맞는 곳약점
검색엔진 동의어 사전·형태소 분석이미 쓰는 검색엔진(OpenSearch·Elasticsearch 등)에 단어 짝을 넣는다. 오타는 퍼지 검색사전을 사람이 관리한다 — 규칙과 같은 일
LLM 에 JSON 으로 필터를 받기처음 보는 단어("밤색 → 갈색", "언니 → 여성")를 언어 지식으로 푼다호출당 수백 ms 이상, 비용, 검색어를 밖으로 보낸다
임베딩 최근접과거 검색어 중 가장 비슷한 것의 필터를 빌린다로그가 많아야 한다, 처음 보는 단어는 여전히 어렵다
LAYA정해진 선택지 판단, 로컬에서, 고친 기록으로 자동 학습본 단어만 잘한다, 처음 보는 단어에 과신

unseen 단어가 이 사례의 남은 구멍이다. 이 구멍은 실시간 검색보다 하루 한 번 새 검색어를 모아 LLM 에 사전 후보를 만들게 하고, 사람이 승인해 사전에 넣는 쪽이 맞아 보인다. 이 가설은 다음 편(22편)에서 실제로 쟀다. 사전이 커지자 LAYA 가 메우는 빈칸의 값도 커져서, 이 편의 결론이 조금 바뀐다.

06 결론 — 이 사례에 LAYA 는 유의미한가

혼자서는 아니다. 섞으면 그렇다.

  • 필터 칸 대부분은 단어 하나가 값을 거의 바로 가리킨다(패딩 → padding, 브라운 → brown). 판단보다 조회에 가깝고, 조회는 사전이 더 정확하고 빠르다.
  • 가격은 숫자 계산이다. 정규식으로 금액을 꺼내 구간을 정하는 편이 확실하다.
  • LAYA 는 사전이 못 찾은 칸을 메울 때 쓸모가 있다. 오타, 사전에 아직 없는 표현, 단어만으로 정하기 어려운 칸이다. 그리고 칩을 고친 기록으로 사람 손 없이 따라잡는다.

이 사례라면 이렇게 짓는다.

  1. 가격은 정규식, 품목·색·성별은 사전, 분류·겨울옷은 품목 표로 정한다.
  2. 규칙이 못 찾은 칸만 LAYA 에 묻고, 확신 0.9 이상일 때만 필터로 건다(19편).
  3. 필터 결과가 0건이면 LAYA 가 건 필터를 빼고 다시 검색한다.
  4. 칩을 고친 기록은 두 곳에 쓴다. 사전에 넣을 단어 후보(사람이 승인)와 LAYA 재학습 데이터(16편의 순서)다.

문의 분류 사례는 달랐다. "결제하기를 누르면 화면이 하얘져요" 를 결제팀이 아니라 기술팀으로 보내려면 문장 전체를 읽어야 한다. 단어 사전으로는 풀기 어렵고, 그래서 LAYA 가 학습 전 0.550 에서 0.815 까지 의미 있게 올랐다. 답이 단어에 있으면 사전, 문장 전체에 있으면 LAYA. 두 사례를 나란히 놓고 얻은 기준이다.

07 실습

  1. rules.py 의 WORDS_V2 에 unseen 단어 몇 개("밤색"·"아들"·"터틀넥")를 넣고 compare.py 를 다시 돌린다. 사전 한 줄이 무엇을 바꾸는지 본다.
  2. compare.py 의 섞는 규칙을 반대로 바꿔 본다(LAYA 확신이 0.9 이상이면 LAYA, 아니면 규칙). 어느 쪽이 나은지 본다.
  3. 내 서비스의 판단 하나를 골라 "답이 단어에 있나, 문장 전체에 있나" 를 적어 본다. 그것이 사전과 모델 중 무엇부터 시도할지를 정한다.