diff --git a/ontology/src/serving/serving.controller.ts b/ontology/src/serving/serving.controller.ts index 11584c8..ca67886 100644 --- a/ontology/src/serving/serving.controller.ts +++ b/ontology/src/serving/serving.controller.ts @@ -36,6 +36,12 @@ export class ServingController { : this.matcher.fusion(body.query ?? '', limit); } + /** 검색어(지역·유형·표기 변형) → 맞는 숙소들과 각 숙소의 SEO 메타 */ + @Post('lodgings/search') + searchLodgings(@Body() body: { query: string; limit?: number }) { + return this.serving.searchLodgings(body.query ?? '', Math.min(Math.max(1, body.limit ?? 10), 50)); + } + /** 성과 피드백 주입 (Search Console / 유입 로그) */ @Post('sites/:id/performance') performance( diff --git a/ontology/src/serving/serving.service.ts b/ontology/src/serving/serving.service.ts index b3f15ea..5b25b54 100644 --- a/ontology/src/serving/serving.service.ts +++ b/ontology/src/serving/serving.service.ts @@ -6,6 +6,14 @@ import { normalizeKeyword } from '../keywords/normalize'; import { EmbeddingProvider } from '../embedding/types'; import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service'; +const LODGING_ROOT = 'stay'; +// 검색어 하나당 살펴볼 가까운 키워드 수. 한 숙소가 연결한 키워드는 15개 안팎이라 이 정도면 겹친다. +const SEARCH_KEYWORD_DEPTH = 60; +// ★ 짧은 한글 키워드는 전혀 다른 말도 코사인이 높다(dedup.service 실측: '선유도 펜션' ↔ '새만금 펜션' = 0.936). +// 그래서 벡터 갈래는 이 값 이상만 "같은 뜻" 으로 치고, 지역 구분은 벡터가 아니라 위치 갈래에 맡긴다. +const SEARCH_FLOOR = 0.94; +const SEARCH_SEO_KEYWORDS = 20; + export interface SeoPayload { merchant: { id: string; externalId: string; name: string; siteUrl: string | null }; title: string; @@ -173,6 +181,73 @@ export class ServingService { return row?.n ?? 0; } + /** + * 검색어 → 그 말로 찾을 만한 숙소와 각 숙소의 SEO 메타. + * + * 두 갈래를 합친다. + * 키워드 — 검색어와 가까운 키워드(표기 변형은 aliases 로 흡수돼 있다)를 **가진** 숙소 + * 위치 — 검색어에 지역 이름이 들어 있으면 그 지역(하위 포함) 숙소 + * 위치가 맞는 숙소를 앞에 둔다. `군산 오션뷰` 로 찾는데 여수 오션뷰 숙소가 먼저 나오면 틀린 답이다. + * + * ★ 업종은 숙박 뿌리(stay.*)로 묶는다 — 펜션을 찾아도 관련 키워드를 가진 호텔이 함께 나온다(2026-09-30 지시). + * ★ 키워드 갈래는 merchant_keyword(생성 때 연결된 것)만 본다. /v1/match 의 추천은 저장되지 않으므로 + * 생성이 한 번도 안 돈 숙소는 위치 갈래로만 잡힌다. + */ + async searchLodgings(rawQuery: string, limit: number) { + const query = rawQuery.trim(); + if (!query) return { input: query, results: [] }; + + const [embedding] = await this.embedder.embed([query], 'query'); + const vec = toVector(embedding); + const norm = normalizeKeyword(query); + + const rows = await this.sql>` + WITH near AS ( + SELECT id, 1 - (embedding <=> ${vec}::vector) AS score + FROM keyword + WHERE embedding IS NOT NULL + ORDER BY embedding <=> ${vec}::vector + LIMIT ${SEARCH_KEYWORD_DEPTH} + ), hits AS ( + SELECT id, score FROM near WHERE score >= ${SEARCH_FLOOR} + UNION + SELECT id, 1.0 FROM keyword WHERE normalized = ${norm} OR ${query} = ANY(aliases) + ), places AS ( + SELECT path FROM region WHERE nlevel(path) > 1 AND position(name IN ${query}) > 0 + ) + SELECT m.external_id, r.name AS region, i.name AS industry, + COALESCE(bool_or(r.path <@ p.path), false) AS region_hit, + max(h.score) AS score, + array_remove(array_agg(DISTINCT k.canonical), NULL) AS matched + FROM merchant m + JOIN industry i ON i.id = m.industry_id AND i.path <@ ${LODGING_ROOT}::ltree + LEFT JOIN region r ON r.id = m.region_id + LEFT JOIN places p ON r.path <@ p.path + LEFT JOIN merchant_keyword mk ON mk.merchant_id = m.id AND mk.status = 'active' + LEFT JOIN hits h ON h.id = mk.keyword_id + LEFT JOIN keyword k ON k.id = h.id + GROUP BY m.id, r.name, i.name + HAVING max(h.score) IS NOT NULL OR bool_or(p.path IS NOT NULL) + ORDER BY region_hit DESC, score DESC NULLS LAST, m.name + LIMIT ${limit}`; + + const results = []; + for (const r of rows) { + results.push({ + region: r.region, + industry: r.industry, + regionMatched: r.region_hit, + score: r.score === null ? null : Number(r.score), + matchedKeywords: r.matched ?? [], + seo: await this.seo(r.external_id, SEARCH_SEO_KEYWORDS), + }); + } + return { input: query, results }; + } + async searchKeywords(query: string, limit: number) { const [embedding] = await this.embedder.embed([query], 'query'); return this.keywords.searchByVector(embedding, 'ko-KR', limit);