매칭을 속성별 다중 질의 + 가중 RRF + 사실 기반 필터로 재구성
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측: 통짜는 점수 폭 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>
This commit is contained in:
parent
1addd7a270
commit
f7485aab32
56
README.md
56
README.md
@ -137,7 +137,7 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
|||||||
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
|
| `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 |
|
||||||
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
|
| `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags |
|
||||||
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
|
| `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints |
|
||||||
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드** (데모 콘솔이 쓰는 API) |
|
| `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) |
|
||||||
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
|
| `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) |
|
||||||
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
|
| `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) |
|
||||||
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
|
| `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 |
|
||||||
@ -174,6 +174,60 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8'
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 매칭 — 속성별 다중 질의 + 사실 기반 필터
|
||||||
|
|
||||||
|
프로필을 통짜로 한 벡터에 넣으면 속성이 희석된다. 실측:
|
||||||
|
|
||||||
|
| 방식 | 점수 범위 | 폭 |
|
||||||
|
|---|---|---|
|
||||||
|
| 통짜 질의문 하나 | 0.8761 ~ 0.8837 | 0.0076 |
|
||||||
|
| 속성별로 쪼갠 질의 | 0.8552 ~ 0.9176 | **0.0624** |
|
||||||
|
|
||||||
|
976건이 전부 0.87 언저리에 뭉쳐 순위는 매기지만 변별하지 못하는 상태였다.
|
||||||
|
그래서 프로필을 레인으로 쪼개 각각 임베딩하고 가중 RRF 로 융합한다.
|
||||||
|
|
||||||
|
| 레인 | 가중치 | 질의문 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| 유형 | 1.0 | `군산 펜션 독채 감성숙소` |
|
||||||
|
| 위치 | 0.7 | `원도심 신흥동 말랭이마을 동국사 근처` |
|
||||||
|
| 동반자 | 0.6 | `커플 친구 가족 혼자` |
|
||||||
|
| 시설 | 0.6 | `프라이빗` |
|
||||||
|
|
||||||
|
레인 설계에서 실측으로 배운 것 세 가지.
|
||||||
|
|
||||||
|
- **브랜드 레인을 두면 안 된다.** 상호는 사전에 없으므로 결국 `군산 펜션` 만 남아
|
||||||
|
가장 generic 한 것들을 끌어온다. 넣었더니 상위 6개가 전부 `~예약` 으로 도배됐다.
|
||||||
|
- **레인끼리 겹치면 안 된다.** 권역과 인근을 따로 두었더니 `신흥동` 토큰이 양쪽에 걸려
|
||||||
|
위치 키워드가 상위를 쓸어갔고, 정작 핵심인 `군산 펜션 독채` 가 8위로 밀렸다. 한 레인으로 합쳤다.
|
||||||
|
- **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
||||||
|
깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다.
|
||||||
|
|
||||||
|
#### 사실 기반 필터 — 벡터가 못 거르는 것
|
||||||
|
|
||||||
|
임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다.
|
||||||
|
|
||||||
|
| 규칙 | 예시 |
|
||||||
|
|---|---|
|
||||||
|
| 수용 인원 | 최대 4인 → `군산 단체 독채펜션`, `군산 독채 세미나실 펜션` 배제 |
|
||||||
|
| 권역 불일치 | 원도심 업체 → `선유도`·`오션뷰` 계열 배제 |
|
||||||
|
| 미보유 시설 | `수영장` 없음 → `군산 독채 온수풀 펜션` 배제 |
|
||||||
|
| **미확인 시설** | `바베큐` 가 `unverified` → 배제하지 않고 **보류** 표시 |
|
||||||
|
|
||||||
|
마지막 항목이 중요하다. 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름"이다.
|
||||||
|
스테이머뭄 기준 61건이 배제됐고, 배제 사유는 응답의 `excluded` 로 함께 내려준다.
|
||||||
|
|
||||||
|
#### 레인별 출력 = SEO 페이지 배분
|
||||||
|
|
||||||
|
응답의 `byLane` 은 레인별 상위 8건이다. 평평한 순위보다 이쪽이 실무에 쓰인다 —
|
||||||
|
한 페이지의 주력 키워드는 1개여야 하므로, **레인 1위가 그 페이지의 주력**이 된다.
|
||||||
|
|
||||||
|
| 레인 | → 페이지 | 주력 |
|
||||||
|
|---|---|---|
|
||||||
|
| 유형 | 메인 | 군산 펜션 독채 |
|
||||||
|
| 위치 | 주변 여행 | 신흥동 일본식가옥 근처 숙소 |
|
||||||
|
| 동반자 | 객실 | 군산 커플 프라이빗 펜션 |
|
||||||
|
| 시설 | 시설 | 군산 프라이빗 펜션 |
|
||||||
|
|
||||||
### 매칭 예시
|
### 매칭 예시
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
158
public/demo.html
158
public/demo.html
@ -85,6 +85,31 @@
|
|||||||
.err{background:var(--surface);border:1px solid var(--stop);color:var(--stop);
|
.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}
|
border-radius:6px;padding:14px 16px;font-size:13.5px;margin-bottom:16px}
|
||||||
.tblwrap{overflow-x:auto}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -98,6 +123,10 @@
|
|||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<input type="text" id="q" value="스테이 머뭄" placeholder="업체명 또는 문장" autocomplete="off">
|
<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">
|
<select id="limit">
|
||||||
<option value="30">상위 30</option>
|
<option value="30">상위 30</option>
|
||||||
<option value="50" selected>상위 50</option>
|
<option value="50" selected>상위 50</option>
|
||||||
@ -112,6 +141,8 @@
|
|||||||
<div class="grid">
|
<div class="grid">
|
||||||
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
|
<aside class="card" id="side"><div class="empty">업체 정보</div></aside>
|
||||||
<section>
|
<section>
|
||||||
|
<div class="lanes" id="lanes"></div>
|
||||||
|
<div class="tabs" id="tabs"></div>
|
||||||
<div class="toolbar" id="filters"></div>
|
<div class="toolbar" id="filters"></div>
|
||||||
<div class="card" style="padding:0">
|
<div class="card" style="padding:0">
|
||||||
<div class="tblwrap"><table id="tbl">
|
<div class="tblwrap"><table id="tbl">
|
||||||
@ -126,15 +157,23 @@
|
|||||||
<script>
|
<script>
|
||||||
const API = location.origin;
|
const API = location.origin;
|
||||||
const $ = (s) => document.querySelector(s);
|
const $ = (s) => document.querySelector(s);
|
||||||
let LAST = null, FILTER = null;
|
let LAST = null, FILTER = null, MODE = 'fusion', TAB = 'fused';
|
||||||
|
|
||||||
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
|
const EXAMPLES = ['스테이 머뭄', '스테이머뭄', '군산 애견동반 펜션', '강아지랑 갈 수 있는 바다 근처 숙소',
|
||||||
'아이랑 물놀이 하기 좋은 곳', '선유도 근처에서 바베큐 되는 독채'];
|
'아이랑 물놀이 하기 좋은 곳', '말랭이마을 걸어서 갈 수 있는 숙소'];
|
||||||
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
|
$('#ex').innerHTML = EXAMPLES.map(e => `<button type="button" data-q="${e}">${e}</button>`).join('');
|
||||||
$('#ex').addEventListener('click', (e) => {
|
$('#ex').addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('button'); if (!b) return;
|
const b = e.target.closest('button'); if (!b) return;
|
||||||
$('#q').value = b.dataset.q; run();
|
$('#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() {
|
async function health() {
|
||||||
try {
|
try {
|
||||||
@ -151,13 +190,13 @@ async function run() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(API + '/v1/match', {
|
const res = await fetch(API + '/v1/match', {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ query, limit: Number($('#limit').value) }),
|
body: JSON.stringify({ query, limit: Number($('#limit').value), mode: MODE }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
LAST = await res.json(); FILTER = null;
|
LAST = await res.json(); FILTER = null; TAB = 'fused';
|
||||||
renderSide(); renderFilters(); renderTable();
|
renderSide(); renderLanes(); renderTabs(); renderBody();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$('#err').innerHTML = `<div class="err">요청 실패 — ${String(e.message || e).slice(0, 300)}</div>`;
|
$('#err').innerHTML = `<div class="err">요청 실패 — ${esc(String(e.message || e)).slice(0, 300)}</div>`;
|
||||||
} finally { $('#go').disabled = false; }
|
} finally { $('#go').disabled = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,64 +204,123 @@ function renderSide() {
|
|||||||
const m = LAST.resolved;
|
const m = LAST.resolved;
|
||||||
if (!m) {
|
if (!m) {
|
||||||
$('#side').innerHTML = `<h2>업체 미해석</h2>
|
$('#side').innerHTML = `<h2>업체 미해석</h2>
|
||||||
<p style="font-size:13px;color:var(--muted);margin:0">
|
<p style="font-size:13px;color:var(--muted);margin:0">일치하는 업체가 없어 입력 문장을 그대로 질의로 씁니다.</p>`;
|
||||||
일치하는 업체가 없어 입력 문장을 그대로 질의로 사용했습니다.</p>
|
|
||||||
<div class="qtext"><b>질의문</b><br>${esc(LAST.queryText)}</div>`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const p = m.profile || {};
|
const p = m.profile || {};
|
||||||
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
|
const arr = (k) => Array.isArray(p[k]) ? p[k] : [];
|
||||||
const chips = (k, label) => arr(k).length
|
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>` : '';
|
? `<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 = `
|
$('#side').innerHTML = `
|
||||||
<h2>해석된 업체</h2>
|
<h2>해석된 업체</h2>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
|
<dt>상호</dt><dd><b>${esc(m.name)}</b></dd>
|
||||||
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
|
<dt>지역</dt><dd>${esc(m.region || '-')}</dd>
|
||||||
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
|
<dt>업종</dt><dd>${esc(m.industry || '-')}</dd>
|
||||||
<dt>ID</dt><dd style="font-family:var(--mono);font-size:11.5px">${esc(m.externalId)}</dd>
|
|
||||||
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
|
<dt>소개</dt><dd>${esc(m.description || '-')}</dd>
|
||||||
${chips('services', '서비스')}${chips('features', '시설')}
|
${p.address ? `<dt>주소</dt><dd>${esc(p.address)}</dd>` : ''}
|
||||||
${chips('audiences', '동반자')}${chips('nearby', '인근')}
|
${chips('features', '특징')}${chips('audiences', '동반자')}${chips('nearby', '인근')}
|
||||||
</dl>
|
</dl>
|
||||||
<div class="qtext"><b>임베딩에 사용한 질의문</b><br>${esc(LAST.queryText)}</div>`;
|
${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() {
|
function renderFilters() {
|
||||||
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
|
const cats = [...new Set(LAST.matches.map(m => m.category).filter(Boolean))];
|
||||||
$('#filters').innerHTML =
|
$('#filters').innerHTML =
|
||||||
`<button class="filter" data-c="" aria-pressed="true">전체</button>` +
|
`<button class="filter" data-c="" aria-pressed="${!FILTER}">전체</button>` +
|
||||||
cats.map(c => `<button class="filter" data-c="${esc(c)}" aria-pressed="false">${esc(c)}</button>`).join('') +
|
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.matches.length}건</span>`;
|
`<span class="count">사전 ${LAST.total.toLocaleString()}건 · ${LAST.mode === 'fusion' ? '융합' : '통짜'}</span>`;
|
||||||
$('#filters').onclick = (e) => {
|
$('#filters').onclick = (e) => {
|
||||||
const b = e.target.closest('.filter'); if (!b) return;
|
const b = e.target.closest('.filter'); if (!b) return;
|
||||||
FILTER = b.dataset.c || null;
|
FILTER = b.dataset.c || null;
|
||||||
[...$('#filters').querySelectorAll('.filter')]
|
[...$('#filters').querySelectorAll('.filter')]
|
||||||
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
|
.forEach(x => x.setAttribute('aria-pressed', String((x.dataset.c || null) === FILTER)));
|
||||||
renderTable();
|
renderTable(LAST.matches);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable() {
|
function row(r, i, metric) {
|
||||||
const rows = LAST.matches.filter(m => !FILTER || m.category === FILTER);
|
const val = metric === 'rrf' ? r.rrf : r.score;
|
||||||
const tb = $('#tbl tbody');
|
return `<tr>
|
||||||
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5"><div class="empty">결과 없음</div></td></tr>'; return; }
|
|
||||||
const max = Math.max(...rows.map(r => r.score)), min = Math.min(...rows.map(r => r.score));
|
|
||||||
const norm = (s) => max === min ? 100 : 6 + 94 * (s - min) / (max - min);
|
|
||||||
tb.innerHTML = rows.map((r, i) => `
|
|
||||||
<tr>
|
|
||||||
<td class="rank">${i + 1}</td>
|
<td class="rank">${i + 1}</td>
|
||||||
<td class="kw">${esc(r.canonical)}
|
<td class="kw">${esc(r.canonical)}
|
||||||
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
|
${r.kind === 'tag' ? '<span class="chip">태그</span>' : ''}
|
||||||
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
|
${r.linked ? '<span class="linked">· 연결됨</span>' : ''}
|
||||||
${r.aliases?.length ? `<small>흡수: ${r.aliases.map(esc).join(', ')}</small>` : ''}
|
${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>
|
||||||
<td><div style="display:flex;align-items:center;gap:8px">
|
<td><span class="score">${metric === 'rrf' ? val.toFixed(5) : val.toFixed(4)}</span></td>
|
||||||
<div class="bar"><i style="width:${norm(r.score).toFixed(1)}%"></i></div>
|
|
||||||
<span class="score">${r.score.toFixed(3)}</span></div></td>
|
|
||||||
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
|
<td><span class="tag t-${esc(r.intent)}">${esc(r.intent)}</span></td>
|
||||||
<td class="cat">${esc(r.category || '-')}</td>
|
<td class="cat">${esc(r.category || '-')}</td>
|
||||||
</tr>`).join('');
|
</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 =>
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
|
||||||
|
|||||||
164
src/serving/match.rules.ts
Normal file
164
src/serving/match.rules.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* 매칭 규칙 테이블.
|
||||||
|
*
|
||||||
|
* 두 종류가 있다.
|
||||||
|
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
|
||||||
|
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다
|
||||||
|
* (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MerchantFacts {
|
||||||
|
name: string;
|
||||||
|
region: string | null;
|
||||||
|
industry: string | null;
|
||||||
|
description: string;
|
||||||
|
address: string | null;
|
||||||
|
areaGroup: AreaGroup | null;
|
||||||
|
capacityMax: number | null;
|
||||||
|
services: string[];
|
||||||
|
features: string[];
|
||||||
|
audiences: string[];
|
||||||
|
nearby: string[];
|
||||||
|
amenities: Set<string>; // 정규화된 보유 시설
|
||||||
|
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AreaGroup = '해안·도서' | '원도심' | '시내';
|
||||||
|
|
||||||
|
export interface Lane {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────── 권역 판정
|
||||||
|
const AREA_TERMS: Record<AreaGroup, string[]> = {
|
||||||
|
'해안·도서': ['선유도', '무녀도', '장자도', '대장도', '신시도', '야미도', '고군산군도', '새만금',
|
||||||
|
'비응항', '비응도', '오식도', '해수욕장', '몽돌', '오션뷰', '바다뷰', '해변', '섬'],
|
||||||
|
'원도심': ['원도심', '신흥동', '영화동', '월명', '말랭이마을', '동국사', '초원사진관', '이성당',
|
||||||
|
'경암동', '근대역사', '근대문화', '시간여행', '해망굴', '째보선창', '뜬다리', '일본식가옥'],
|
||||||
|
'시내': ['나운동', '수송동', '미룡동', '조촌동', '은파호수공원', '군산역', '시외버스'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectAreaGroup(text: string): AreaGroup | null {
|
||||||
|
const hits = (Object.entries(AREA_TERMS) as [AreaGroup, string[]][])
|
||||||
|
.map(([g, terms]) => [g, terms.filter((t) => text.includes(t)).length] as const)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
return hits[0]?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 키워드가 어느 권역에 속하는지 (해당 없으면 null = 권역 중립) */
|
||||||
|
export function keywordAreaGroup(keyword: string): AreaGroup | null {
|
||||||
|
return detectAreaGroup(keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────── 수용 인원 모순
|
||||||
|
const GROUP_TERMS = ['단체', '워크샵', '워크숍', 'mt', '엠티', '20인', '15인', '10인',
|
||||||
|
'세미나', '단합', '대형', '펜션동', '전체대관'];
|
||||||
|
const GROUP_MIN_CAPACITY = 8;
|
||||||
|
|
||||||
|
export function violatesCapacity(keyword: string, capacityMax: number | null): boolean {
|
||||||
|
if (capacityMax === null || capacityMax >= GROUP_MIN_CAPACITY) return false;
|
||||||
|
const k = keyword.toLowerCase();
|
||||||
|
return GROUP_TERMS.some((t) => k.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────── 시설 요구 조건
|
||||||
|
/** 키워드에 이 말이 있으면 해당 시설을 실제로 보유해야 한다 */
|
||||||
|
const AMENITY_REQUIRED: Array<[RegExp, string]> = [
|
||||||
|
[/바베큐|바비큐|바베큐장|그릴/, '바베큐'],
|
||||||
|
[/수영장|풀빌라|인피니티풀|온수풀|야외수영/, '수영장'],
|
||||||
|
[/스파|자쿠지|월풀|반신욕/, '스파'],
|
||||||
|
[/애견|반려|펫/, '애견동반'],
|
||||||
|
[/주차/, '주차'],
|
||||||
|
[/노래방/, '노래방'],
|
||||||
|
[/조식/, '조식'],
|
||||||
|
[/키즈룸|트램폴린/, '키즈시설'],
|
||||||
|
[/불멍|화로대|캠프파이어/, '불멍'],
|
||||||
|
[/빔프로젝터|넷플릭스/, '미디어'],
|
||||||
|
[/세미나실/, '세미나실'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 시설 표기 흔들림을 통제 어휘로 모은다 */
|
||||||
|
const AMENITY_SYNONYMS: Array<[RegExp, string]> = [
|
||||||
|
[/바베큐|바비큐|그릴/, '바베큐'],
|
||||||
|
[/수영장|풀|풀빌라/, '수영장'],
|
||||||
|
[/스파|자쿠지|월풀|욕조/, '스파'],
|
||||||
|
[/애견|반려|펫/, '애견동반'],
|
||||||
|
[/주차/, '주차'],
|
||||||
|
[/노래방/, '노래방'],
|
||||||
|
[/조식|아침/, '조식'],
|
||||||
|
[/키즈|트램폴린|유아/, '키즈시설'],
|
||||||
|
[/불멍|화로|캠프파이어/, '불멍'],
|
||||||
|
[/넷플릭스|빔프로젝터|ott/i, '미디어'],
|
||||||
|
[/세미나/, '세미나실'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeAmenities(raw: string[]): Set<string> {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const r of raw) {
|
||||||
|
for (const [re, canon] of AMENITY_SYNONYMS) if (re.test(r)) out.add(canon);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AmenityVerdict = { ok: true } | { ok: false; hold: boolean; amenity: string };
|
||||||
|
|
||||||
|
export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerdict {
|
||||||
|
for (const [re, amenity] of AMENITY_REQUIRED) {
|
||||||
|
if (!re.test(keyword)) continue;
|
||||||
|
if (facts.amenities.has(amenity)) return { ok: true };
|
||||||
|
// 사업자가 확인해주지 않은 항목은 "없음"이 아니라 "모름" — 배제하지 않고 보류
|
||||||
|
const unverified = [...facts.unverified].some((u) =>
|
||||||
|
AMENITY_SYNONYMS.some(([sre, canon]) => canon === amenity && sre.test(u)));
|
||||||
|
return { ok: false, hold: unverified, amenity };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────── 서브 질의 빌더
|
||||||
|
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
|
||||||
|
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레인 설계 원칙
|
||||||
|
* 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고,
|
||||||
|
* 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다.
|
||||||
|
* 지역+업종 앵커는 유형 레인에만 둔다.
|
||||||
|
* 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아
|
||||||
|
* 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다).
|
||||||
|
* 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다.
|
||||||
|
*/
|
||||||
|
export function buildLanes(f: MerchantFacts): Lane[] {
|
||||||
|
const lanes: Lane[] = [];
|
||||||
|
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
|
||||||
|
const text = [...new Set(parts.filter(Boolean) as string[])].join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
if (text) lanes.push({ key, label, weight, text });
|
||||||
|
};
|
||||||
|
|
||||||
|
const isType = (x: string) => STAY_TYPE_HINTS.some((h) => x.includes(h));
|
||||||
|
const typeWords = [...f.features, ...f.services].filter(isType).slice(0, 3);
|
||||||
|
|
||||||
|
// 시설: 유형어·수용인원·권역어를 걷어낸 나머지 + 정규화된 보유 시설
|
||||||
|
const amenityWords = [
|
||||||
|
...f.amenities,
|
||||||
|
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
|
||||||
|
].slice(0, 6);
|
||||||
|
|
||||||
|
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐
|
||||||
|
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
|
||||||
|
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
|
||||||
|
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
|
||||||
|
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
||||||
|
push('amenity', '시설', 0.6, amenityWords);
|
||||||
|
|
||||||
|
return lanes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function districtOf(address: string | null): string | null {
|
||||||
|
if (!address) return null;
|
||||||
|
const m = address.match(/([가-힣]+(?:동|읍|면|리))/);
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
225
src/serving/match.service.ts
Normal file
225
src/serving/match.service.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { Sql, toVector } from '../db/db';
|
||||||
|
import { PG } from '../db/db.module';
|
||||||
|
import { EmbeddingProvider } from '../embedding/types';
|
||||||
|
import { normalizeKeyword } from '../keywords/normalize';
|
||||||
|
import { MerchantWithTaxonomy } from '../merchants/merchants.service';
|
||||||
|
import {
|
||||||
|
AreaGroup, Lane, MerchantFacts, buildLanes, checkAmenity, detectAreaGroup,
|
||||||
|
keywordAreaGroup, normalizeAmenities, violatesCapacity,
|
||||||
|
} from './match.rules';
|
||||||
|
|
||||||
|
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
||||||
|
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
|
||||||
|
const RRF_K = 20;
|
||||||
|
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
|
||||||
|
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
|
||||||
|
|
||||||
|
interface Hit {
|
||||||
|
id: string; canonical: string; intent: string; kind: string;
|
||||||
|
category: string | null; aliases: string[]; score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchRow extends Hit {
|
||||||
|
rrf: number;
|
||||||
|
status: 'ok' | 'hold';
|
||||||
|
holdReason?: string;
|
||||||
|
lanes: Array<{ key: string; label: string; rank: number; score: number }>;
|
||||||
|
linked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatchService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PG) private readonly sql: Sql,
|
||||||
|
private readonly embedder: EmbeddingProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 속성별 서브 질의 → 가중 RRF 융합 → 사실 기반 필터 */
|
||||||
|
async fusion(rawQuery: string, limit: number) {
|
||||||
|
const query = rawQuery.trim();
|
||||||
|
const merchant = await this.resolveMerchant(query);
|
||||||
|
const facts = merchant ? toFacts(merchant) : null;
|
||||||
|
|
||||||
|
const lanes: Lane[] = facts
|
||||||
|
? buildLanes(facts)
|
||||||
|
: [{ key: 'free', label: '입력문', weight: 1.0, text: query }];
|
||||||
|
|
||||||
|
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
||||||
|
|
||||||
|
// 레인별 검색
|
||||||
|
const perLane = await Promise.all(
|
||||||
|
vectors.map((v) => this.laneSearch(v, LANE_DEPTH)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 가중 RRF 융합
|
||||||
|
const acc = new Map<string, { hit: Hit; rrf: number; lanes: MatchRow['lanes'] }>();
|
||||||
|
perLane.forEach((hits, li) => {
|
||||||
|
const lane = lanes[li];
|
||||||
|
hits.forEach((hit, idx) => {
|
||||||
|
if (hit.score < LANE_FLOOR) return;
|
||||||
|
const rank = idx + 1;
|
||||||
|
const contrib = lane.weight / (RRF_K + rank);
|
||||||
|
const cur = acc.get(hit.id) ?? { hit, rrf: 0, lanes: [] };
|
||||||
|
cur.rrf += contrib;
|
||||||
|
cur.lanes.push({ key: lane.key, label: lane.label, rank, score: hit.score });
|
||||||
|
if (hit.score > cur.hit.score) cur.hit = hit;
|
||||||
|
acc.set(hit.id, cur);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사실 기반 필터
|
||||||
|
const kept: MatchRow[] = [];
|
||||||
|
const excluded: Array<{ canonical: string; reason: string }> = [];
|
||||||
|
for (const { hit, rrf, lanes: ls } of acc.values()) {
|
||||||
|
if (facts) {
|
||||||
|
if (violatesCapacity(hit.canonical, facts.capacityMax)) {
|
||||||
|
excluded.push({ canonical: hit.canonical, reason: `최대 ${facts.capacityMax}인 — 단체 키워드` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kwArea = keywordAreaGroup(hit.canonical);
|
||||||
|
if (kwArea && facts.areaGroup && kwArea !== facts.areaGroup) {
|
||||||
|
excluded.push({ canonical: hit.canonical, reason: `권역 불일치 — ${kwArea} (업체는 ${facts.areaGroup})` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const am = checkAmenity(hit.canonical, facts);
|
||||||
|
if (!am.ok && !am.hold) {
|
||||||
|
excluded.push({ canonical: hit.canonical, reason: `미보유 시설 — ${am.amenity}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.push({
|
||||||
|
...hit, rrf,
|
||||||
|
lanes: ls.sort((a, b) => a.rank - b.rank),
|
||||||
|
status: am.ok ? 'ok' : 'hold',
|
||||||
|
holdReason: am.ok ? undefined : `${am.amenity} 미확인 — 사업자 확인 필요`,
|
||||||
|
linked: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
kept.push({ ...hit, rrf, lanes: ls.sort((a, b) => a.rank - b.rank), status: 'ok', linked: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept.sort((a, b) => b.rrf - a.rrf);
|
||||||
|
const top = kept.slice(0, limit);
|
||||||
|
|
||||||
|
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
|
||||||
|
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
|
||||||
|
const keptById = new Map(kept.map((k) => [k.id, k]));
|
||||||
|
const byLane = lanes.map((lane, li) => ({
|
||||||
|
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
|
||||||
|
items: perLane[li]
|
||||||
|
.map((h) => keptById.get(h.id))
|
||||||
|
.filter((x): x is MatchRow => Boolean(x))
|
||||||
|
.slice(0, 8),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.markLinked([...top, ...byLane.flatMap((l) => l.items)], merchant?.id ?? null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'fusion' as const,
|
||||||
|
input: query,
|
||||||
|
resolved: merchant ? publicMerchant(merchant) : null,
|
||||||
|
facts: facts && {
|
||||||
|
areaGroup: facts.areaGroup,
|
||||||
|
capacityMax: facts.capacityMax,
|
||||||
|
amenities: [...facts.amenities],
|
||||||
|
unverified: [...facts.unverified],
|
||||||
|
},
|
||||||
|
lanes: lanes.map((l, i) => ({
|
||||||
|
...l, top: perLane[i][0]?.canonical ?? null, topScore: perLane[i][0]?.score ?? null,
|
||||||
|
})),
|
||||||
|
embeddingProvider: this.embedder.name,
|
||||||
|
total: await this.dictionarySize(),
|
||||||
|
matches: top,
|
||||||
|
byLane,
|
||||||
|
excluded: excluded.slice(0, 40),
|
||||||
|
excludedTotal: excluded.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async laneSearch(embedding: number[], limit: number): Promise<Hit[]> {
|
||||||
|
const vec = toVector(embedding);
|
||||||
|
const rows = await this.sql<Hit[]>`
|
||||||
|
SELECT id, canonical, intent, kind, category, aliases,
|
||||||
|
1 - (embedding <=> ${vec}::vector) AS score
|
||||||
|
FROM keyword
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> ${vec}::vector
|
||||||
|
LIMIT ${limit}`;
|
||||||
|
return rows.map((r) => ({ ...r, score: Number(r.score) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markLinked(rows: MatchRow[], merchantId: string | null) {
|
||||||
|
if (!merchantId || rows.length === 0) return;
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
const linked = await this.sql<Array<{ keyword_id: string }>>`
|
||||||
|
SELECT keyword_id FROM merchant_keyword
|
||||||
|
WHERE merchant_id = ${merchantId} AND keyword_id = ANY(${ids}::uuid[])`;
|
||||||
|
const set = new Set(linked.map((l) => l.keyword_id));
|
||||||
|
for (const r of rows) r.linked = set.has(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveMerchant(query: string) {
|
||||||
|
const norm = normalizeKeyword(query);
|
||||||
|
if (!norm) return null;
|
||||||
|
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
|
||||||
|
SELECT m.*,
|
||||||
|
i.name AS industry_name, i.path::text AS industry_path,
|
||||||
|
r.name AS region_name, r.path::text AS region_path,
|
||||||
|
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
|
||||||
|
FROM merchant m
|
||||||
|
LEFT JOIN industry i ON i.id = m.industry_id
|
||||||
|
LEFT JOIN region r ON r.id = m.region_id
|
||||||
|
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
|
||||||
|
OR m.external_id = ${query}
|
||||||
|
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
|
||||||
|
ORDER BY sim DESC NULLS LAST
|
||||||
|
LIMIT 1`;
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dictionarySize() {
|
||||||
|
const [row] = await this.sql<Array<{ n: number }>>`
|
||||||
|
SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`;
|
||||||
|
return row?.n ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
|
||||||
|
const p = (m.profile ?? {}) as Record<string, unknown>;
|
||||||
|
const features = str(p['features']);
|
||||||
|
const services = str(p['services']);
|
||||||
|
const nearby = str(p['nearby']);
|
||||||
|
const address = typeof p['address'] === 'string' ? p['address'] : null;
|
||||||
|
const cap = p['capacity'] as { max?: number } | undefined;
|
||||||
|
|
||||||
|
const areaSource = [address ?? '', ...nearby, m.description].join(' ');
|
||||||
|
const areaGroup: AreaGroup | null = detectAreaGroup(areaSource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: m.name,
|
||||||
|
region: m.region_name,
|
||||||
|
industry: m.industry_name,
|
||||||
|
description: m.description,
|
||||||
|
address,
|
||||||
|
areaGroup,
|
||||||
|
capacityMax: typeof cap?.max === 'number' ? cap.max : null,
|
||||||
|
services, features,
|
||||||
|
audiences: str(p['audiences']),
|
||||||
|
nearby,
|
||||||
|
amenities: normalizeAmenities([...features, ...services]),
|
||||||
|
unverified: new Set(str(p['unverified'])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMerchant(m: MerchantWithTaxonomy) {
|
||||||
|
return {
|
||||||
|
id: m.id, externalId: m.external_id, name: m.name,
|
||||||
|
region: m.region_name, industry: m.industry_name,
|
||||||
|
description: m.description, siteUrl: m.site_url, profile: m.profile ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,9 +1,13 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { MatchService } from './match.service';
|
||||||
import { ServingService } from './serving.service';
|
import { ServingService } from './serving.service';
|
||||||
|
|
||||||
@Controller('v1')
|
@Controller('v1')
|
||||||
export class ServingController {
|
export class ServingController {
|
||||||
constructor(private readonly serving: ServingService) {}
|
constructor(
|
||||||
|
private readonly serving: ServingService,
|
||||||
|
private readonly matcher: MatchService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** 발행된 사이트가 렌더링 시 호출 — SEO 메타 */
|
/** 발행된 사이트가 렌더링 시 호출 — SEO 메타 */
|
||||||
@Get('sites/:id/seo')
|
@Get('sites/:id/seo')
|
||||||
@ -23,10 +27,17 @@ export class ServingController {
|
|||||||
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드 */
|
/**
|
||||||
|
* 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드.
|
||||||
|
* mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터
|
||||||
|
* mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용)
|
||||||
|
*/
|
||||||
@Post('match')
|
@Post('match')
|
||||||
match(@Body() body: { query: string; limit?: number }) {
|
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
|
||||||
return this.serving.match(body.query ?? '', Math.min(body.limit ?? 40, 200));
|
const limit = Math.min(body.limit ?? 40, 200);
|
||||||
|
return body.mode === 'single'
|
||||||
|
? this.serving.match(body.query ?? '', limit)
|
||||||
|
: this.matcher.fusion(body.query ?? '', limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 성과 피드백 주입 (Search Console / 유입 로그) */
|
/** 성과 피드백 주입 (Search Console / 유입 로그) */
|
||||||
|
|||||||
@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
|
|||||||
import { KeywordsModule } from '../keywords/keywords.module';
|
import { KeywordsModule } from '../keywords/keywords.module';
|
||||||
import { MerchantsModule } from '../merchants/merchants.module';
|
import { MerchantsModule } from '../merchants/merchants.module';
|
||||||
import { DemoController } from './demo.controller';
|
import { DemoController } from './demo.controller';
|
||||||
|
import { MatchService } from './match.service';
|
||||||
import { ServingController } from './serving.controller';
|
import { ServingController } from './serving.controller';
|
||||||
import { ServingService } from './serving.service';
|
import { ServingService } from './serving.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MerchantsModule, KeywordsModule],
|
imports: [MerchantsModule, KeywordsModule],
|
||||||
controllers: [ServingController, DemoController],
|
controllers: [ServingController, DemoController],
|
||||||
providers: [ServingService],
|
providers: [ServingService, MatchService],
|
||||||
})
|
})
|
||||||
export class ServingModule {}
|
export class ServingModule {}
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export class ServingService {
|
|||||||
LIMIT ${limit}`;
|
LIMIT ${limit}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
mode: 'single' as const,
|
||||||
input: query,
|
input: query,
|
||||||
resolved: merchant
|
resolved: merchant
|
||||||
? {
|
? {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user