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; // 이 코사인 미만은 그 레인에서 기여하지 않는다 // 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. const MATCH_SOURCES = ['dataset', 'nationwide', 'manual']; 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, merchant?.industry_id ?? null)), ); // 가중 RRF 융합 const acc = new Map(); 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, industryId: string | null, ): Promise { const vec = toVector(embedding); const rows = await this.sql` SELECT id, canonical, intent, kind, category, aliases, 1 - (embedding <=> ${vec}::vector) AS score FROM keyword WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES}) AND (${industryId}::text IS NULL OR industry_id IS NULL OR industry_id = ${industryId}) 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>` 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>` 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>` SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`; 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; 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'])), signals: collectSignals(p), }; } /** * 고객 언어 신호를 모은다. * hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등 * reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계 * 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다. */ function collectSignals(p: Record): string[] { const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean); const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : []; const reviews = raw .filter((r): r is { term: string; count: number } => Boolean(r) && typeof (r as any).term === 'string') .sort((a, b) => (b.count ?? 0) - (a.count ?? 0)) .map((r) => r.term.trim()) .filter(Boolean); return [...new Set([...reviews, ...tags])]; } 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 ?? {}, }; }