[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:
hbyang 2026-09-30 16:00:22 +09:00
parent 092a350182
commit 283970b2ba
2 changed files with 81 additions and 0 deletions

View File

@ -36,6 +36,12 @@ export class ServingController {
: this.matcher.fusion(body.query ?? '', limit); : 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 / 유입 로그) */ /** 성과 피드백 주입 (Search Console / 유입 로그) */
@Post('sites/:id/performance') @Post('sites/:id/performance')
performance( performance(

View File

@ -6,6 +6,14 @@ import { normalizeKeyword } from '../keywords/normalize';
import { EmbeddingProvider } from '../embedding/types'; import { EmbeddingProvider } from '../embedding/types';
import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service'; 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 { export interface SeoPayload {
merchant: { id: string; externalId: string; name: string; siteUrl: string | null }; merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
title: string; title: string;
@ -173,6 +181,73 @@ export class ServingService {
return row?.n ?? 0; 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) { async searchKeywords(query: string, limit: number) {
const [embedding] = await this.embedder.embed([query], 'query'); const [embedding] = await this.embedder.embed([query], 'query');
return this.keywords.searchByVector(embedding, 'ko-KR', limit); return this.keywords.searchByVector(embedding, 'ko-KR', limit);