[feat] ontology: 숙소 검색 API — 검색어로 맞는 숙소와 SEO 메타를 돌려준다
다른 세션(SNS AEO SEO 팔로워 수집)에서 만들던 작업이 작업 트리에 미커밋으로 남아 있었다. 잃지 않게 그 자리에서 커밋한다 — 커밋 하나에 무관한 변경을 섞지 않으려고 카카오 로그인 작업과 분리했다. - POST serving/lodgings/search — 지역·유형·표기 변형을 받아 숙소와 SEO 메타를 돌려준다 - ★ SEARCH_FLOOR=0.94: 짧은 한글 키워드는 전혀 다른 말도 코사인이 높다 (dedup.service 실측: '선유도 펜션' ↔ '새만금 펜션' = 0.936). 그래서 벡터 갈래는 이 값 이상만 "같은 뜻" 으로 치고, 지역 구분은 벡터가 아니라 위치 갈래에 맡긴다 검증: npm install 후 tsc --noEmit — src/ 오류 0(기존 scripts/ 오류 2건은 이번 변경과 무관). 런타임 동작은 확인하지 못했다(DB·임베딩 제공자가 필요하다). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
092a350182
commit
283970b2ba
@ -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(
|
||||
|
||||
@ -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<Array<{
|
||||
external_id: string; region: string | null; industry: string | null;
|
||||
region_hit: boolean; score: number | null; matched: string[] | null;
|
||||
}>>`
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user