② 사전 보강 — 실측으로 찾은 어휘 공백을 메움
- 인원: 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>
183 lines
8.3 KiB
TypeScript
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;
|
|
}
|