o2o-site-ontology/src/serving/serving.service.ts
hbyang 209a381091 SEO/AEO 키워드 온톨로지 서비스 초기 구현
발행된 사이트에 업체별 SEO/AEO 키워드를 제공하는 서비스.

- PostgreSQL 16 + pgvector/ltree/pg_trgm 단일 스토어
  (정확·의미·계층 조회를 한 엔진에서 처리)
- 키워드는 전역 사전 + merchant_keyword 연결 테이블 구조
- 4단계 계단식 중복제거: 금칙어 → normalized 완전일치 →
  pg_trgm → 코사인 유사도, 걸린 표기는 aliases[] 로 흡수
- BullMQ 생성 큐 (발행 즉시 / 일 1회 크론 / 성과 기반)
- OpenAI Structured Outputs + mock provider
  (API 키 없이 로컬 전 구간 동작)
- 서빙 API: /v1/sites/:id/seo, /aeo, /performance, /keywords/search
- docs/architecture.html 설계 도식

JSON-LD 조립과 o2o-site-AEO 연동은 후속 작업.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:56:34 +09:00

145 lines
5.4 KiB
TypeScript

import { Inject, Injectable } from '@nestjs/common';
import { PG } from '../db/db.module';
import { Sql } from '../db/db';
import { KeywordRepository } from '../keywords/keyword.repository';
import { LlmProvider } from '../llm/types';
import { MerchantsService } from '../merchants/merchants.service';
export interface SeoPayload {
merchant: { id: string; externalId: string; name: string; siteUrl: string | null };
title: string;
description: string;
keywords: string[];
tags: Array<{ keyword: string; intent: string; relevance: number; aliases: string[] }>;
generatedAt: string | null;
}
export interface AeoPayload {
merchant: { id: string; externalId: string; name: string };
topics: string[];
faqs: Array<{ question: string; answer: string }>;
/** JSON-LD 는 후속 단계에서 이 payload 를 그대로 매핑해 생성한다 */
structuredDataHints: {
type: 'LocalBusiness';
name: string;
description: string;
areaServed: string | null;
category: string | null;
};
}
@Injectable()
export class ServingService {
constructor(
@Inject(PG) private readonly sql: Sql,
private readonly merchants: MerchantsService,
private readonly keywords: KeywordRepository,
private readonly llm: LlmProvider,
) {}
async seo(idOrExternalId: string, limit: number): Promise<SeoPayload> {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
const rows = await this.sql<
Array<{ canonical: string; intent: string; relevance: number; aliases: string[] }>
>`
SELECT k.canonical, k.intent, mk.relevance, k.aliases
FROM merchant_keyword mk
JOIN keyword k ON k.id = mk.keyword_id
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
ORDER BY mk.relevance DESC, k.usage_count ASC
LIMIT ${limit}`;
const kws = rows.map((r) => r.canonical);
const locality = [m.region_name, m.industry_name].filter(Boolean).join(' ');
return {
merchant: { id: m.id, externalId: m.external_id, name: m.name, siteUrl: m.site_url },
title: locality ? `${m.name} | ${locality}` : m.name,
description: buildDescription(m.name, m.description, kws),
keywords: kws,
tags: rows.map((r) => ({
keyword: r.canonical,
intent: r.intent,
relevance: Number(r.relevance),
aliases: r.aliases ?? [],
})),
generatedAt: m.last_generated_at ? new Date(m.last_generated_at).toISOString() : null,
};
}
async aeo(idOrExternalId: string, limit: number): Promise<AeoPayload> {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
const faqs = await this.sql<Array<{ question: string; answer: string }>>`
SELECT question, answer FROM qa_pair
WHERE merchant_id = ${m.id} AND status = 'active'
ORDER BY created_at ASC
LIMIT ${limit}`;
const topics = await this.sql<Array<{ canonical: string }>>`
SELECT k.canonical FROM merchant_keyword mk
JOIN keyword k ON k.id = mk.keyword_id
WHERE mk.merchant_id = ${m.id} AND mk.status = 'active'
AND k.intent IN ('informational', 'local')
ORDER BY mk.relevance DESC LIMIT ${limit}`;
return {
merchant: { id: m.id, externalId: m.external_id, name: m.name },
topics: topics.map((t) => t.canonical),
faqs,
structuredDataHints: {
type: 'LocalBusiness',
name: m.name,
description: m.description,
areaServed: m.region_name,
category: m.industry_name,
},
};
}
async searchKeywords(query: string, limit: number) {
const [embedding] = await this.llm.embed([query]);
return this.keywords.searchByVector(embedding, 'ko-KR', limit);
}
/** Search Console / 유입 로그 피드백 → 저성과 키워드 강등 */
async applyPerformance(
idOrExternalId: string,
items: Array<{ keyword: string; impressions: number; clicks: number }>,
) {
const m = await this.merchants.findWithTaxonomy(idOrExternalId);
let updated = 0;
for (const it of items) {
const rows = await this.sql<Array<{ keyword_id: string }>>`
UPDATE merchant_keyword mk
SET impressions = mk.impressions + ${it.impressions},
clicks = mk.clicks + ${it.clicks},
ctr = CASE WHEN (mk.impressions + ${it.impressions}) > 0
THEN (mk.clicks + ${it.clicks})::real / (mk.impressions + ${it.impressions})
ELSE 0 END,
updated_at = now()
FROM keyword k
WHERE k.id = mk.keyword_id
AND mk.merchant_id = ${m.id}
AND (k.canonical = ${it.keyword} OR ${it.keyword} = ANY(k.aliases))
RETURNING mk.keyword_id`;
updated += rows.length;
}
// 노출은 충분한데 클릭이 없는 키워드는 강등 → 다음 생성 사이클에서 대체
const demoted = await this.sql<Array<{ keyword_id: string }>>`
UPDATE merchant_keyword
SET status = 'demoted', updated_at = now()
WHERE merchant_id = ${m.id} AND status = 'active'
AND impressions >= 100 AND ctr < 0.002
RETURNING keyword_id`;
return { matched: updated, demoted: demoted.length };
}
}
function buildDescription(name: string, desc: string, keywords: string[]): string {
const base = desc?.trim() || `${name} 안내`;
const tail = keywords.slice(0, 3).join(', ');
const full = tail ? `${base} ${tail} 정보를 확인하세요.` : base;
return full.length > 155 ? `${full.slice(0, 152)}...` : full;
}