프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측: 통짜는 점수 폭 0.0076, 속성별로 쪼개면 0.0624 (8배). - src/serving/match.rules.ts — 레인 빌더, 권역 판정, 수용 인원/시설 필터, 시설 통제 어휘 정규화 - src/serving/match.service.ts — 레인별 검색 → 가중 RRF 융합 → 필터 → 레인별 그룹(byLane) 출력. 근거(어느 레인 몇 위)를 함께 반환 - POST /v1/match 에 mode=fusion(기본) / single(기존 통짜, 비교용) - 데모 페이지: 모드 토글, 레인 카드, 융합/레인별/배제됨 탭 레인 설계에서 실측으로 고친 것 - 브랜드 레인 제거 — 상호는 사전에 없어 generic '군산 펜션 ~예약'만 끌어왔다 - 권역/인근 레인 병합 — '신흥동' 토큰이 겹쳐 위치 키워드가 상위를 쓸어갔다 - RRF 상수 60 → 20 — 60은 1위/40위 기여도 차이가 1.6배뿐이라 generic이 유리했다 사실 기반 필터는 벡터가 못 거르는 모순을 배제한다. 스테이머뭄(최대 4인, 원도심) 기준 61건 배제. 미확인 시설은 배제하지 않고 '보류'로 표시한다 — 없음이 아니라 모름이므로. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334 lines
17 KiB
HTML
334 lines
17 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>키워드 매칭 콘솔 · o2o-site-ontology</title>
|
|
<style>
|
|
:root{
|
|
--bg:#f4f6f5; --surface:#fff; --surface2:#eceff0; --ink:#101819; --ink-soft:#3d4c4e;
|
|
--muted:#63757a; --line:#d5dcdb; --line-soft:#e4e9e8;
|
|
--accent:#0d6a60; --accent-bg:#dff0ec; --warn:#8a5a06; --warn-bg:#f6ead2;
|
|
--stop:#9d3a30; --info:#1d5b7a; --info-bg:#dceaf2;
|
|
--sans:'Apple SD Gothic Neo',system-ui,-apple-system,'Malgun Gothic',sans-serif;
|
|
--mono:'Menlo','SFMono-Regular',Consolas,monospace;
|
|
}
|
|
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
|
|
--bg:#0d1213; --surface:#141b1c; --surface2:#1b2425; --ink:#e7edeb; --ink-soft:#c2cecd;
|
|
--muted:#8d9d9f; --line:#263130; --line-soft:#1e2728;
|
|
--accent:#56c2b1; --accent-bg:#12312e; --warn:#d7a34a; --warn-bg:#33270f;
|
|
--stop:#e28a80; --info:#7fb6d4; --info-bg:#142c3a;
|
|
}}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.6}
|
|
.wrap{max-width:1180px;margin:0 auto;padding:32px 24px 80px}
|
|
header{border-bottom:1px solid var(--line);padding-bottom:20px;margin-bottom:24px}
|
|
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.12em;color:var(--accent);margin:0 0 8px}
|
|
h1{margin:0 0 6px;font-size:26px;letter-spacing:-.01em}
|
|
.sub{margin:0;color:var(--muted);font-size:14px}
|
|
.status{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;font-family:var(--mono);font-size:11px;color:var(--muted)}
|
|
.status span{border:1px solid var(--line);background:var(--surface);border-radius:3px;padding:3px 9px}
|
|
|
|
form{display:flex;gap:10px;margin:0 0 20px;flex-wrap:wrap}
|
|
input[type=text]{flex:1;min-width:260px;padding:12px 14px;font-size:15px;font-family:var(--sans);
|
|
background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px}
|
|
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
|
|
select,button{padding:12px 16px;font-size:14px;font-family:var(--sans);border-radius:5px;border:1px solid var(--line);
|
|
background:var(--surface);color:var(--ink)}
|
|
button{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;cursor:pointer}
|
|
button:disabled{opacity:.5;cursor:progress}
|
|
.examples{display:flex;gap:6px;flex-wrap:wrap;margin:-8px 0 22px}
|
|
.examples button{background:var(--surface);color:var(--muted);border:1px solid var(--line);
|
|
font-weight:400;font-size:12px;padding:5px 11px;border-radius:20px}
|
|
.examples button:hover{color:var(--accent);border-color:var(--accent)}
|
|
|
|
.grid{display:grid;grid-template-columns:320px 1fr;gap:22px;align-items:start}
|
|
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
|
.card{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:18px}
|
|
.card h2{margin:0 0 12px;font-size:14px;letter-spacing:.02em}
|
|
.kv{display:grid;grid-template-columns:72px 1fr;gap:4px 10px;font-size:13px}
|
|
.kv dt{color:var(--muted)}
|
|
.kv dd{margin:0;color:var(--ink-soft)}
|
|
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:4px}
|
|
.chip{font-size:11px;font-family:var(--mono);background:var(--surface2);color:var(--ink-soft);
|
|
padding:2px 7px;border-radius:3px}
|
|
.qtext{margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);
|
|
font-family:var(--mono);font-size:11.5px;color:var(--muted);word-break:break-all;line-height:1.7}
|
|
|
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
|
|
.toolbar .count{font-family:var(--mono);font-size:12px;color:var(--muted);margin-left:auto}
|
|
.filter{font-size:12px;font-family:var(--mono);padding:4px 10px;border-radius:20px;
|
|
border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
|
|
.filter[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
|
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
|
th{text-align:left;font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;color:var(--muted);
|
|
text-transform:uppercase;padding:8px 10px;border-bottom:1px solid var(--line);font-weight:600}
|
|
td{padding:9px 10px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
|
|
tr:hover td{background:var(--surface2)}
|
|
.rank{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;
|
|
font-variant-numeric:tabular-nums}
|
|
.kw{font-weight:500}
|
|
.kw small{display:block;font-family:var(--mono);font-size:10.5px;color:var(--muted);font-weight:400}
|
|
.bar{position:relative;width:120px;height:7px;background:var(--surface2);border-radius:4px;overflow:hidden}
|
|
.bar i{position:absolute;inset:0 auto 0 0;background:var(--accent);border-radius:4px}
|
|
.score{font-family:var(--mono);font-size:11.5px;color:var(--ink-soft);width:48px;
|
|
font-variant-numeric:tabular-nums;text-align:right}
|
|
.tag{font-size:10.5px;font-family:var(--mono);padding:2px 7px;border-radius:3px;white-space:nowrap}
|
|
.t-local{background:var(--accent-bg);color:var(--accent)}
|
|
.t-transactional{background:var(--warn-bg);color:var(--warn)}
|
|
.t-informational{background:var(--info-bg);color:var(--info)}
|
|
.t-brand{background:var(--surface2);color:var(--ink-soft)}
|
|
.cat{font-size:11px;color:var(--muted);font-family:var(--mono)}
|
|
.linked{font-size:10.5px;font-family:var(--mono);color:var(--accent)}
|
|
.empty{padding:40px;text-align:center;color:var(--muted);font-size:14px}
|
|
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
|
|
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
|
|
.tblwrap{overflow-x:auto}
|
|
.modes{display:flex;gap:0;border:1px solid var(--line);border-radius:5px;overflow:hidden}
|
|
.modes button{border:0;border-radius:0;background:var(--surface);color:var(--muted);font-size:13px;padding:12px 16px;font-weight:500}
|
|
.modes button[aria-pressed=true]{background:var(--accent);color:#fff}
|
|
.lanes{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin-bottom:16px}
|
|
.lane{background:var(--surface);border:1px solid var(--line);border-radius:6px;padding:11px 13px}
|
|
.lane b{font-size:12.5px}
|
|
.lane .w{font-family:var(--mono);font-size:10.5px;color:var(--accent);margin-left:5px}
|
|
.lane .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin-top:5px;line-height:1.55;word-break:break-all}
|
|
.tabs{display:flex;gap:6px;margin-bottom:12px}
|
|
.tabs button{font-size:12.5px;padding:6px 13px;border-radius:20px;border:1px solid var(--line);
|
|
background:var(--surface);color:var(--muted);font-weight:400}
|
|
.tabs button[aria-pressed=true]{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
.prov{display:inline-flex;gap:4px;flex-wrap:wrap}
|
|
.prov span{font-family:var(--mono);font-size:10px;background:var(--surface2);color:var(--muted);
|
|
padding:1px 5px;border-radius:3px}
|
|
.hold{font-size:10.5px;font-family:var(--mono);color:var(--warn);background:var(--warn-bg);
|
|
padding:1px 6px;border-radius:3px;white-space:nowrap}
|
|
.facts{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px}
|
|
.facts span{font-family:var(--mono);font-size:11px;background:var(--surface2);color:var(--ink-soft);
|
|
padding:3px 8px;border-radius:3px}
|
|
.lanegroup{margin-bottom:20px}
|
|
.lanegroup h3{margin:0 0 4px;font-size:13.5px}
|
|
.lanegroup .q{font-family:var(--mono);font-size:11px;color:var(--muted);margin:0 0 8px}
|
|
.exrow td{color:var(--muted)}
|
|
.exwhy{font-family:var(--mono);font-size:11px;color:var(--stop)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header>
|
|
<p class="eyebrow">O2O-SITE-ONTOLOGY</p>
|
|
<h1>키워드 매칭 콘솔</h1>
|
|
<p class="sub">업체명이나 문장을 넣으면 적재된 “군산 펜션” 키워드 사전에서 잘 맞는 것을 골라 보여줍니다.</p>
|
|
<div class="status" id="status"><span>연결 확인 중…</span></div>
|
|
</header>
|
|
|
|
<form id="f">
|
|
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
|
|
<div class="modes">
|
|
<button type="button" id="m-fusion" aria-pressed="true">융합</button>
|
|
<button type="button" id="m-single" aria-pressed="false">통짜</button>
|
|
</div>
|
|
<select id="limit">
|
|
<option value="30">상위 30</option>
|
|
<option value="50" selected>상위 50</option>
|
|
<option value="100">상위 100</option>
|
|
</select>
|
|
<button type="submit" id="go">매칭</button>
|
|
</form>
|
|
<div class="examples" id="ex"></div>
|
|
|
|
<div id="err"></div>
|
|
|
|
<div class="grid">
|
|
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
|
|
<section>
|
|
<div class="lanes" id="lanes"></div>
|
|
<div class="tabs" id="tabs"></div>
|
|
<div class="toolbar" id="filters"></div>
|
|
<div class="card" style="padding:0">
|
|
<div class="tblwrap"><table id="tbl">
|
|
<thead><tr><th class="rank">#</th><th>키워드</th><th>유사도</th><th>의도</th><th>카테고리</th></tr></thead>
|
|
<tbody><tr><td colspan="5"><div class="empty">매칭 버튼을 눌러 시작하세요</div></td></tr></tbody>
|
|
</table></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = location.origin;
|
|
const $ = (s) => document.querySelector(s);
|
|
let LAST = null, FILTER = null, MODE = 'fusion', TAB = 'fused';
|
|
|
|
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
|
|
'아이랑 물놀이 하기 좋은 곳', '말랭이마을 걸어서 갈 수 있는 숙소'];
|
|
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
|
|
$('#ex').addEventListener('click', (e) => {
|
|
const b = e.target.closest('button'); if (!b) return;
|
|
$('#q').value = b.dataset.q; run();
|
|
});
|
|
for (const m of ['fusion', 'single']) {
|
|
$('#m-' + m).addEventListener('click', () => {
|
|
MODE = m;
|
|
$('#m-fusion').setAttribute('aria-pressed', String(m === 'fusion'));
|
|
$('#m-single').setAttribute('aria-pressed', String(m === 'single'));
|
|
run();
|
|
});
|
|
}
|
|
|
|
async function health() {
|
|
try {
|
|
const h = await (await fetch(API + '/health')).json();
|
|
$('#status').innerHTML =
|
|
`<span>API ${h.status}</span><span>임베딩 ${h.embeddingProvider}</span><span>생성 ${h.llmProvider}</span>`;
|
|
} catch { $('#status').innerHTML = '<span>API 연결 실패 — npm start 실행 중인지 확인</span>'; }
|
|
}
|
|
|
|
async function run() {
|
|
const query = $('#q').value.trim();
|
|
if (!query) return;
|
|
$('#go').disabled = true; $('#err').innerHTML = '';
|
|
try {
|
|
const res = await fetch(API + '/v1/match', {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ query, limit: Number($('#limit').value), mode: MODE }),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
LAST = await res.json(); FILTER = null; TAB = 'fused';
|
|
renderSide(); renderLanes(); renderTabs(); renderBody();
|
|
} catch (e) {
|
|
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
|
|
} finally { $('#go').disabled = false; }
|
|
}
|
|
|
|
function renderSide() {
|
|
const m = LAST.resolved;
|
|
if (!m) {
|
|
$('#side').innerHTML = `<h2>업체 미해석</h2>
|
|
<p style="font-size:13px;color:var(--muted);margin:0">일치하는 업체가 없어 입력 문장을 그대로 질의로 씁니다.</p>`;
|
|
return;
|
|
}
|
|
const p = m.profile || {};
|
|
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
|
|
const chips = (k, label) => arr(k).length
|
|
? `<dt>${label}</dt><dd><div class="chips">${arr(k).map(v => `<span class="chip">${esc(v)}</span>`).join('')}</div></dd>` : '';
|
|
const f = LAST.facts;
|
|
$('#side').innerHTML = `
|
|
<h2>해석된 업체</h2>
|
|
<dl class="kv">
|
|
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
|
|
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
|
|
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
|
|
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
|
|
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
|
|
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
|
|
</dl>
|
|
${f ? `<div class="qtext"><b>필터에 쓰는 사실</b>
|
|
<div class="facts">
|
|
<span>권역 ${esc(f.areaGroup || '미상')}</span>
|
|
<span>최대 ${f.capacityMax ?? '?'}인</span>
|
|
${f.amenities.map(a => `<span>${esc(a)}</span>`).join('')}
|
|
${f.unverified.map(u => `<span style="color:var(--warn)">${esc(u)}?</span>`).join('')}
|
|
</div></div>` : ''}
|
|
${LAST.mode === 'single' ? `<div class="qtext"><b>임베딩에 사용한 질의문 (통짜)</b><br>${esc(LAST.queryText)}</div>` : ''}`;
|
|
}
|
|
|
|
function renderLanes() {
|
|
if (LAST.mode !== 'fusion') { $('#lanes').innerHTML = ''; return; }
|
|
$('#lanes').innerHTML = LAST.lanes.map(l => `
|
|
<div class="lane">
|
|
<b>${esc(l.label)}</b><span class="w">w=${l.weight}</span>
|
|
<div class="q">${esc(l.text)}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
function renderTabs() {
|
|
if (LAST.mode !== 'fusion') { $('#tabs').innerHTML = ''; return; }
|
|
const tabs = [['fused', `융합 순위 ${LAST.matches.length}`],
|
|
['lanes', '레인별 (페이지 배분)'],
|
|
['excluded', `배제됨 ${LAST.excludedTotal}`]];
|
|
$('#tabs').innerHTML = tabs.map(([k, label]) =>
|
|
`<button data-t="${k}" aria-pressed="${k === TAB}">${label}</button>`).join('');
|
|
$('#tabs').onclick = (e) => {
|
|
const b = e.target.closest('button'); if (!b) return;
|
|
TAB = b.dataset.t;
|
|
[...$('#tabs').querySelectorAll('button')].forEach(x => x.setAttribute('aria-pressed', String(x.dataset.t === TAB)));
|
|
renderBody();
|
|
};
|
|
}
|
|
|
|
function renderBody() {
|
|
if (LAST.mode === 'fusion' && TAB === 'lanes') return renderLaneGroups();
|
|
if (LAST.mode === 'fusion' && TAB === 'excluded') return renderExcluded();
|
|
renderFilters(); renderTable(LAST.matches);
|
|
}
|
|
|
|
function renderFilters() {
|
|
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
|
|
$('#filters').innerHTML =
|
|
`<button class="filter" data-c="" aria-pressed="${!FILTER}">전체</button>` +
|
|
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="${FILTER === c}">${esc(c)}</button>`).join('') +
|
|
`<span class="count">사전 ${LAST.total.toLocaleString()}건 · ${LAST.mode === 'fusion' ? '융합' : '통짜'}</span>`;
|
|
$('#filters').onclick = (e) => {
|
|
const b = e.target.closest('.filter'); if (!b) return;
|
|
FILTER = b.dataset.c || null;
|
|
[...$('#filters').querySelectorAll('.filter')]
|
|
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
|
|
renderTable(LAST.matches);
|
|
};
|
|
}
|
|
|
|
function row(r, i, metric) {
|
|
const val = metric === 'rrf' ? r.rrf : r.score;
|
|
return `<tr>
|
|
<td class="rank">${i + 1}</td>
|
|
<td class="kw">${esc(r.canonical)}
|
|
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
|
|
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
|
|
${r.status === 'hold' ? `<span class="hold">보류 · ${esc(r.holdReason || '')}</span>` : ''}
|
|
${r.lanes ? `<small class="prov">${r.lanes.slice(0, 4).map(l => `<span>${esc(l.label)}${l.rank}</span>`).join('')}</small>` : ''}
|
|
</td>
|
|
<td><span class="score">${metric === 'rrf' ? val.toFixed(5) : val.toFixed(4)}</span></td>
|
|
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
|
|
<td class="cat">${esc(r.category || '-')}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
function renderTable(rows) {
|
|
const list = rows.filter(m => !FILTER || m.category === FILTER);
|
|
const metric = LAST.mode === 'fusion' ? 'rrf' : 'cos';
|
|
$('#tbl thead').innerHTML =
|
|
`<tr><th class="rank">#</th><th>키워드</th><th>${metric === 'rrf' ? 'RRF' : '유사도'}</th><th>의도</th><th>카테고리</th></tr>`;
|
|
$('#tbl tbody').innerHTML = list.length
|
|
? list.map((r, i) => row(r, i, metric)).join('')
|
|
: '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>';
|
|
}
|
|
|
|
function renderLaneGroups() {
|
|
$('#filters').innerHTML = '<span class="count">레인 1위가 그 페이지의 주력 키워드가 된다</span>';
|
|
$('#tbl').closest('.card').innerHTML = '<div style="padding:18px">' + LAST.byLane.map(l => `
|
|
<div class="lanegroup">
|
|
<h3>${esc(l.label)} <span class="chip">w=${l.weight}</span></h3>
|
|
<p class="q">${esc(l.text)}</p>
|
|
<table><tbody>${l.items.map((r, i) => row(r, i, 'cos')).join('')}</tbody></table>
|
|
</div>`).join('') + '</div>';
|
|
}
|
|
|
|
function renderExcluded() {
|
|
$('#filters').innerHTML = `<span class="count">사실 기반 필터로 걸러낸 ${LAST.excludedTotal}건 — 벡터만으로는 못 거른다</span>`;
|
|
$('#tbl thead').innerHTML = '<tr><th class="rank">#</th><th>키워드</th><th colspan="3">배제 사유</th></tr>';
|
|
$('#tbl tbody').innerHTML = LAST.excluded.length
|
|
? LAST.excluded.map((e, i) => `<tr class="exrow"><td class="rank">${i + 1}</td>
|
|
<td class="kw">${esc(e.canonical)}</td>
|
|
<td colspan="3" class="exwhy">${esc(e.reason)}</td></tr>`).join('')
|
|
: '<tr><td colspan="5"><div class="empty">배제된 항목 없음</div></td></tr>';
|
|
}
|
|
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
|
$('#f').addEventListener('submit', (e) => { e.preventDefault(); run(); });
|
|
health(); run();
|
|
</script>
|
|
</body>
|
|
</html>
|