o2o-site-ontology/src/serving/match.rules.ts
hbyang 25f2cc871d 스테이머뭄 매칭 품질 개선 4건
② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
   - 인원: 2인·3인 추가 (4/6/10/20인만 있어 기준 2인 업체가 노릴 키워드가 없었다)
   - 단독 명사형: 군산 독채 / 스테이 / 풀빌라 (조합형만 있어 단독 검색을 놓쳤다)
   - 분위기 11종(감성·조용한·사진찍기 좋은·인생샷 …), 여행형태 9종(뚜벅이·1박2일 …)
   - scripts/import-related.ts — 검색광고 키워드도구 내려받기 병합 (파일 임포터)

③ 사전 오염 정리
   - source='llm' 27건 삭제 (강남 미용실·해운대 한식당이 군산 펜션 사전에 있었다)
   - 매칭 후보를 업체 업종으로 한정 + source IN ('dataset','manual') 만 조회
   - 적재 시 데이터셋에서 빠진 행 삭제 — upsert 만 하면 재빌드마다 누적된다 (974 → 1072)

④ 고객 언어 레인 (w=0.9)
   - hashtags + reviewSignals(원문 아닌 빈도 집계) 를 별도 레인으로
   - 사업자 표현보다 검색어에 가까우므로 유형 다음으로 높게 잡음
   - 스테이머뭄 데이터는 아직 없음 — 인스타는 로그인 월이라 스크래퍼가 채워야 함

⑤ 레인 토큰 중복 제거
   - '신흥동' 과 '신흥동 일본식가옥' 이 별개 원소라 같은 낱말이 두 번 실렸다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 14:09:36 +09:00

183 lines
8.3 KiB
TypeScript

/**
* 매칭 규칙 테이블.
*
* 두 종류가 있다.
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 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>; // 미확인 — 배제하지 않고 보류 처리
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
signals: 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[] = [];
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
const seen = new Set<string>();
const words: string[] = [];
for (const part of parts) {
if (!part) continue;
for (const w of String(part).split(/\s+/)) {
const k = w.trim();
if (!k || seen.has(k)) continue;
seen.add(k);
words.push(k);
}
}
const text = words.join(' ');
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);
// 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다
push('signal', '고객언어', 0.9, f.signals.slice(0, 10));
return lanes;
}
function districtOf(address: string | null): string | null {
if (!address) return null;
const m = address.match(/([가-힣]+(?:동|읍|면|리))/);
return m?.[1] ?? null;
}