홈강좌LAYA 결정 모델 24편 중 22편째
LAYA 쇼핑 검색 6 — LLM 이 사전을 키우고 LAYA 가 빈칸을 메우면: 0.619 에서 0.857 로
21편은 "처음 보는 단어는 어느 방법도 못 푼다" 로 끝났다. 그리고 가설 하나를 남겼다. 하루 한 번 새 검색어를 LLM 에 보내 동의어 후보를 받고, 사람이 승인해 사전에 넣는다. 이 편에서 그 가설을 실제로 잰다. 비교를 위해 검색어마다 LLM 에 직접 필터를 묻는 방식(실시간)도 함께 잰다.
LLM 은 Claude 를 썼다. 이 글에서는 Claude Code CLI 의 claude -p(비대화형 실행)로 불렀다. API 로 부르려면 아래 llm.py 의 함수 하나만 바꾸면 된다.
"""LLM 한 번 부르기 — 이 글에서는 Claude Code CLI(claude -p)를 쓴다. API 로 바꾸려면 이 함수만 바꾼다."""
import json
import subprocess
import tempfile
def ask(prompt, model):
"""답 글자와 API 시간(ms)을 돌려준다. 저장소 설정·도구 없이, 빈 폴더에서 부른다."""
out = subprocess.run(
["claude", "-p", prompt, "--model", model, "--output-format", "json", "--tools", "",
"--setting-sources", "", "--no-session-persistence"],
capture_output=True, text=True, cwd=tempfile.gettempdir(), timeout=300)
reply = json.loads(out.stdout)
if reply.get("is_error"):
raise RuntimeError(reply.get("result"))
return reply["result"], reply.get("duration_api_ms", 0)
def json_part(text):
"""답에서 JSON 부분만 — 코드 울타리(```)로 감싸 올 때가 있다."""
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1].rsplit("```", 1)[0]
return json.loads(text)01 하루치 로그
운영에서 하루 동안 들어온 검색어 300건을 흉내 낸다. 처음 보는 표현(unseen) 50%, 운영 표현(ops) 30%, 처음 표현(train) 20% 다. 평가 세트와 같은 문장은 없지만 같은 단어는 나온다. 실제로도 평가에서 처음 보는 단어는 내일 로그에 먼저 나타나는 단어다.
"""운영 하루치 검색 로그 300건 — 정답 없이 검색어 글자만 남는다고 본다.
처음 보는 표현(unseen) 50% · 운영 표현(ops) 30% · 처음 표현(train) 20%. 평가·학습·운영 300건과 겹치는 문장은 뺀다.
"""
import json
from make_shop import make
seen = set()
for name in ("shop-eval.jsonl", "shop-first-150.jsonl", "shop-incoming-300.jsonl"):
seen |= {json.loads(line)["state"] for line in open(name, encoding="utf-8")}
log = make([("unseen", 0.5), ("ops", 0.3), ("train", 0.2)], 300, seed=21, exclude=seen)
with open("data/shop-log-300.jsonl", "w", encoding="utf-8") as f:
for r in log:
f.write(json.dumps({"state": r["state"], "pool": r["pool"], "expected": r["expected"]}, ensure_ascii=False) + "\n")
print(f"log {len(log)} queries -> data/shop-log-300.jsonl")
for r in log[:5]:
print(" ", r["state"])python make_log.pylog 300 queries -> data/shop-log-300.jsonl
감색 기지 바지 말고 털 부츠 골라 주세요
아빠 100만원 아래
아들 애코백
여친 선물 검은으로 된 울코트 상품중
할아버지 예산 25만 순백 기지 바지 골라 주세요줄마다 정답 칸이 있지만, 다음 단계에서 LLM 에는 검색어 글자만 보낸다. 정답은 후보를 채점할 때만 쓴다.
02 LLM 에게 사전에 없는 말을 묻는다
300건을 모두 보내지 않는다. 21편의 규칙 v2 로 먼저 읽어서, 가격 표현·사전 단어·군말을 지우고도 낱말이 남는 검색어만 고른다. 사전이 설명하지 못한 검색어다. 그것을 40건씩 묶어 LLM(Sonnet)에 보낸다. 프롬프트에는 필터 칸과 값, 이미 사전에 있는 단어를 함께 준다.
"""하루치 검색 로그에서 사전이 설명 못 한 검색어를 골라 LLM 에 동의어 후보를 묻는다.
로그의 정답 칸(expected)은 쓰지 않는다 — 후보가 맞았는지 채점할 때만 make_shop 의 단어 표를 본다.
"""
import json
import re
from collections import Counter
import make_shop as shop
from llm import ask, json_part
from rules import PRICE_V2, merged
MODEL = "claude-sonnet-5-5"
STOP = {"추천", "찾아요", "보여줘", "있나요", "좀", "상품중", "사고", "싶어요", "골라", "주세요", "중에", "된",
"말고", "어울리는", "선물", "선물로", "중"}
VALUES = {
"item": {k: v for k, v in shop.QUESTIONS["item"]["criteria"].items() if k != "none"},
"category": {k: v for k, v in shop.QUESTIONS["category"]["criteria"].items() if k not in ("none", "bag")},
"gender": {"men": "남성", "women": "여성"},
"color": {k: v for k, v in shop.QUESTIONS["color"]["criteria"].items() if k != "none"},
"season": {"winter": "겨울에 쓸 것", "summer": "여름에 쓸 것"},
}
def leftovers(text, words):
"""가격 표현과 사전 단어, 군말을 지우고 남는 낱말."""
text = PRICE_V2.sub(" ", text)
for w in sorted((w for table in words.values() for w in table), key=len, reverse=True):
text = text.replace(w, " ")
tokens = [re.sub(r"(으로|로|에)$", "", t) for t in text.split()]
return [t for t in tokens if len(t) >= 2 and t not in STOP]
def truth_table():
"""채점용 — 생성기의 단어 표(세 묶음 전부)."""
table = {}
for field, src in (("item", shop.ITEMS), ("category", shop.CATEGORY_WORDS), ("gender", shop.GENDERS), ("color", shop.COLORS)):
for value, pools in src.items():
for pool in pools:
for w in pool:
table[w] = (field, value)
for pools, value in ((shop.WINTER_WORDS, "winter"), (shop.SUMMER_WORDS, "summer")):
for pool in pools:
for w in pool:
table[w] = ("season", value)
return table
def jamo(word):
"""한글을 자모로 풀어 쓴다 — 오타(ㅐ↔ㅔ)를 글자 하나가 아니라 자모 하나 차이로 보려고."""
out = []
for ch in word.replace(" ", ""):
code = ord(ch) - 0xAC00
out += [code // 588, 100 + code // 28 % 21, 200 + code % 28] if 0 <= code <= 11171 else [ch]
return out
def distance(a, b):
row = list(range(len(b) + 1))
for i, x in enumerate(a, 1):
prev, row[0] = row[0], i
for j, y in enumerate(b, 1):
prev, row[j] = row[j], min(row[j] + 1, row[j - 1] + 1, prev + (x != y))
return row[-1]
def judge(word, field, value, table):
"""맞음·틀림. 띄어쓰기는 빼고 본다. 표 단어의 일부("언니" ← "언니 선물로")나, 자모 2개 이내로 가장 가까운 표 단어(오타)로 채점한다."""
key = word.replace(" ", "")
flat = {w.replace(" ", ""): v for w, v in table.items()}
if key in flat:
return flat[key] == (field, value)
part = [w for w in flat if key in w or w in key]
if part:
return any(flat[w] == (field, value) for w in part)
best = min(flat, key=lambda w: distance(jamo(w), jamo(key)))
return distance(jamo(best), jamo(key)) <= 2 and flat[best] == (field, value)
words = merged(2)
log = [json.loads(line) for line in open("data/shop-log-300.jsonl", encoding="utf-8")]
unexplained = [r["state"] for r in log if leftovers(r["state"], words)]
known = {f: sorted(words[f if f != "season" else "season"]) for f in ("item", "category", "gender", "color", "season")}
print(f"log {len(log)} · unexplained by the dictionary {len(unexplained)}")
PROMPT = """쇼핑몰 검색어에서, 필터 값을 뜻하는데 아직 사전에 없는 말을 찾아라.
필터 칸과 값:
{values}
이미 사전에 있는 말(다시 적지 않는다): {known}
규칙:
- word 에는 검색어에 나온 글자를 그대로 적는다. 오타여도 원래 뜻의 값으로 적는다.
- gender 는 누가 쓸 상품인지 드러내는 말(가족·관계 포함)이다.
- category 는 품목 없이 분류만 말하는 단어다.
- 필터 값이 아닌 말(추천·선물·골라 주세요·가격 표현 같은)과 확신이 없는 말은 빼라.
- JSON 배열만 답한다: [{{"word": "...", "field": "...", "value": "..."}}]
검색어:
{queries}"""
votes, seconds = Counter(), 0
for i in range(0, len(unexplained), 40):
batch = unexplained[i:i + 40]
prompt = PROMPT.format(
values="\n".join(f"- {f}: " + ", ".join(f"{k}({v})" for k, v in vals.items()) for f, vals in VALUES.items()),
known=json.dumps(known, ensure_ascii=False),
queries="\n".join(f"{n + 1}. {q}" for n, q in enumerate(batch)))
reply, ms = ask(prompt, MODEL)
seconds += ms / 1000
for c in json_part(reply):
ok_value = c.get("field") in VALUES and c.get("value") in VALUES[c["field"]]
if ok_value and any(c["word"] in q for q in batch): # 검색어에 없는 말은 버린다
votes[(c["word"], c["field"], c["value"])] += 1
print(f"LLM calls {-(-len(unexplained) // 40)} ({MODEL}) · {seconds:.0f} s in total")
table = truth_table()
cands = sorted(votes)
right = [c for c in cands if judge(*c, table)]
wrong = [c for c in cands if not judge(*c, table)]
print(f"candidates {len(cands)} · right {len(right)} · wrong {len(wrong)}")
by_field = Counter(f for _, f, _ in right)
print("right by field:", ", ".join(f"{f} {by_field[f]}" for f in VALUES))
print("wrong:", ", ".join(f"{w} -> {f} {v}" for w, f, v in wrong) or "none")
def as_extra(rows):
extra = {f: {} for f in ("item", "category", "gender", "color", "season")}
for w, f, v in rows:
extra[f][w] = {"winter": True, "summer": False}.get(v, v) if f == "season" else v
return extra
json.dump(as_extra(cands), open("data/shop-llm-words.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
json.dump(as_extra(right), open("data/shop-llm-words-approved.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print("saved -> data/shop-llm-words.json (all) · data/shop-llm-words-approved.json (wrong ones removed)")python candidates.pylog 300 · unexplained by the dictionary 166
LLM calls 5 (claude-sonnet-5-5) · 66 s in total
candidates 62 · right 61 · wrong 1
right by field: item 26, category 4, gender 7, color 19, season 5
wrong: 남섹 -> gender men
saved -> data/shop-llm-words.json (all) · data/shop-llm-words-approved.json (wrong ones removed)- 사전이 설명 못 한 검색어는 166건이다. LLM 호출은 5번, 합쳐 1분 남짓이다.
- 후보 62개 가운데 61개가 맞았다. "밤색 → brown", "아들 → men", "폴라티 → knit" 처럼 처음 보는 단어를 언어 지식으로 풀었고, "애코백" 같은 오타도 원래 값으로 돌렸다.
- 틀린 하나는 "남섹 → 남성" 이다. 남색(navy)의 오타인데 남성으로 읽었다. 사람 승인 단계가 필요한 이유다. 여기서는 사람이 틀린 후보를 걸렀다고 보고 61개짜리 사전을 따로 저장했다.
- 검색어에 실제로 나오지 않는 말을 LLM 이 지어내면 버린다(
any(c["word"] in q ...)). 사전에 넣을 단어는 반드시 로그에서 나온 글자여야 한다.
03 사전에 더하고 다시 잰다
"""LLM 이 제안한 단어를 사전에 더하면 같은 평가 세트(210건)에서 얼마나 오르나 — 규칙 v2 · +LLM 단어 · +사람이 거른 단어 · +LAYA v2."""
import json
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")]
all_words = json.load(open("data/shop-llm-words.json", encoding="utf-8"))
approved = json.load(open("data/shop-llm-words-approved.json", encoding="utf-8"))
agent = laya.load("checkpoints/shop-v2")
def laya_table(case):
a = agent.predict(case["state"], case["questions"])["answers"]
p = {f: (a[f]["noul"] >= 0.5 if a[f]["type"] == "noul" else a[f]["choice"]) for f in FIELDS}
if p["item"] != "none":
p["category"] = CATEGORY_OF[p["item"]]
p["winter"] = p["winter"] or p["item"] in WINTER_ITEMS
return p
start = time.time()
lp = [laya_table(c) for c in cases]
laya_ms = (time.time() - start) / len(cases) * 1000
methods = {}
for name, extra in [("rules v2", None), ("rules v2 + LLM words", all_words), ("rules v2 + approved", approved)]:
start = time.time()
methods[name] = ([parse(c["state"], 2, extra)[0] for c in cases], (time.time() - start) / len(cases) * 1000)
mixed = []
for c, p in zip(cases, lp):
r, found = parse(c["state"], 2, approved)
mixed.append({f: (r[f] if f in found else p[f]) for f in FIELDS})
methods["approved + LAYA v2"] = (mixed, laya_ms)
print(f"added words: LLM {sum(map(len, all_words.values()))} · approved {sum(map(len, approved.values()))}")
print(f"{'method':<22}{'exact':>7}{'train':>8}{'ops':>8}{'unseen':>8}{'ms/query':>10}")
for name, (preds, ms) in methods.items():
ok = [all(p[f] == c["expected"][f] for f in FIELDS) for c, p in zip(cases, preds)]
cells = "".join(f"{sum(o for c, o in zip(cases, ok) if c['pool'] == pool) / 70:>8.3f}" for pool in ("train", "ops", "unseen"))
print(f"{name:<22}{sum(ok) / len(ok):>7.3f}{cells}{ms:>10.2f}")
# 섞은 방식에서 LAYA 가 메운 칸 — 검색어에 값이 있는데 사전이 근거를 못 찾아 LAYA 답을 쓴 칸, 그중 맞은 비율.
filled = {f: [0, 0] for f in FIELDS}
for c, p in zip(cases, lp):
_, found = parse(c["state"], 2, approved)
for f in FIELDS:
if f not in found and c["expected"][f] not in ("none", "unknown", False):
filled[f][0] += 1
filled[f][1] += p[f] == c["expected"][f]
print("fields LAYA filled (approved + LAYA v2):", ", ".join(f"{f} {n} ({ok / n:.0%} right)" for f, (n, ok) in filled.items() if n))python compare_llm.pyadded words: LLM 62 · approved 61
method exact train ops unseen ms/query
rules v2 0.619 0.886 0.971 0.000 0.02
rules v2 + LLM words 0.700 0.914 0.986 0.200 0.04
rules v2 + approved 0.700 0.914 0.986 0.200 0.04
approved + LAYA v2 0.857 0.914 0.971 0.686 97.50
fields LAYA filled (approved + LAYA v2): price 56 (79% right), category 3 (0% right), item 2 (0% right), color 5 (40% right)- LLM 단어를 더하자 규칙이 0.619 → 0.700 이다. unseen 단어 묶음이 0.000 → 0.200 으로 올랐지만 아직 낮다.
- 사람이 틀린 후보 하나를 거른 사전도 0.700 으로 같다. 틀린 후보("남섹 → 남성")가 평가 세트에 나오지 않았을 뿐, 승인 단계는 그대로 둔다.
- 사전이 근거를 못 찾은 칸을 LAYA v2 가 메우면 0.857 이다. unseen 단어 묶음이 0.200 → 0.686 으로 뛰었다.
마지막 줄이 LAYA 가 메운 칸이다. 가격이 56칸으로 거의 전부이고 79% 를 맞혔다. "예산 25만"·"오십만 원 이하"·"30만원 넘지 않는" 처럼 사전 단어가 아니라 말투로 된 가격이다. 정규식은 처음 기획 때 본 꼴만 읽고, LAYA 는 운영에서 고친 검색어로 그 말투를 배웠다. 분류·품목·색처럼 사전이 이미 넓은 칸에서는 LAYA 가 메운 몇 칸이 대부분 틀렸다. 사전이 모르는 단어는 LAYA 도 모르는 것이다.
04 비교 — 검색어마다 LLM 에 직접 묻기
같은 210건을 검색어 하나씩 작은 LLM(Haiku)에 보내 필터 여섯 칸을 JSON 으로 받았다.
"""비교 기준 — 검색어마다 LLM 에 직접 필터를 묻는다(실시간). 같은 평가 세트 210건, 작은 모델(Haiku)."""
import json
import statistics
from llm import ask, json_part
MODEL = "claude-haiku-5-5"
FIELDS = ["price", "category", "item", "winter", "gender", "color"]
cases = [json.loads(line) for line in open("shop-eval.jsonl", encoding="utf-8")]
q = cases[0]["questions"]
SPEC = "\n".join(f"- {k}: {q[k]['instructions']} 값: " + ", ".join(f"{a}({b})" for a, b in q[k]["criteria"].items())
for k in FIELDS if k != "winter")
PROMPT = """쇼핑몰 검색어를 검색엔진 필터로 바꿔라. JSON 객체 하나만 답한다.
{spec}
- winter: {winter} 값: true 또는 false
검색어: {query}"""
hits, ms_list, by_pool, bad_json = [], [], {"train": [], "ops": [], "unseen": []}, 0
for c in cases:
reply, ms = ask(PROMPT.format(spec=SPEC, winter=q["winter"]["instructions"], query=c["state"]), MODEL)
ms_list.append(ms)
try:
got = json_part(reply)
except ValueError:
got, bad_json = {}, bad_json + 1
ok = all(got.get(f) == c["expected"][f] for f in FIELDS)
hits.append(ok)
by_pool[c["pool"]].append(ok)
print(f"model {MODEL} · {len(cases)} queries · unreadable JSON {bad_json}")
print(f"exact match {sum(hits) / len(hits):.3f} · " + " · ".join(f"{p} {sum(v) / len(v):.3f}" for p, v in by_pool.items()))
ms_list.sort()
print(f"API time per query: median {statistics.median(ms_list):.0f} ms · 90% {ms_list[int(len(ms_list) * 0.9)]:.0f} ms")python realtime.pymodel claude-haiku-5-5 · 210 queries · unreadable JSON 0
exact match 0.848 · train 0.929 · ops 0.857 · unseen 0.757
API time per query: median 2153 ms · 90% 3136 ms- 완전 일치 0.848, unseen 단어 0.757. 학습도 사전도 없이 질문 문구만으로 이만큼 맞혔다. 처음 보는 단어에서는 이 편의 어느 방법보다 높다.
- 검색어 하나에 API 시간 중앙값 2153ms, 느린 10% 는 3초를 넘는다. 사전은 0.04ms, 사전 + LAYA 는 98ms 다.
- JSON 이 깨진 답은 없었다.
정확도만 보면 실시간 LLM 과 "사전 + LAYA"(0.857)가 비슷하다. 차이는 시간과 비용, 그리고 검색어가 밖으로 나가느냐다. 검색창은 2초를 기다려 주지 않는다.
05 정리
| 방법 | 완전 일치 | unseen 단어 | 검색어 하나 | LLM 을 부르는 때 |
|---|---|---|---|---|
| 규칙 v2(사전·정규식) | 0.619 | 0.000 | 0.02ms | — |
| + LLM 이 키운 사전 | 0.700 | 0.200 | 0.04ms | 하루 한 번(5번 호출) |
| + LLM 이 키운 사전 + LAYA v2 | 0.857 | 0.686 | 98ms | 하루 한 번 |
| 실시간 LLM(Haiku) | 0.848 | 0.757 | 2153ms(중앙값) | 검색마다 |
이 사례의 답은 셋을 각자 잘하는 자리에 두는 것이다. 사전은 아는 단어를 정확하고 빠르게 읽는다. LLM 은 하루 한 번, 사전이 모르는 단어를 사전에 넣을 후보로 바꾼다. LAYA 는 사전이 근거를 못 찾은 칸을 메운다. 21편에서 "LAYA 는 빈칸" 이라고 했는데, 사전이 커지자 그 빈칸의 값이 커졌다.
06 실습
candidates.py의 모델을 Haiku 로 바꿔 후보의 정확도가 얼마나 떨어지는지 본다. 하루 한 번이면 큰 모델을 써도 비용이 작다.- 승인 단계를 흉내 내지 말고 직접 해 본다.
data/shop-llm-words.json을 열어 틀린 후보를 손으로 지우고compare_llm.py를 다시 돌린다. - 가격 표현("예산 25만", "오십만 원 이하")을 정규식에 더해, LAYA 가 메우던 가격 칸을 규칙이 가져가면 무엇이 바뀌는지 본다.