o2o-site-ontology/src/keywords/keyword.repository.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

125 lines
4.1 KiB
TypeScript

import { Inject, Injectable } from '@nestjs/common';
import { PG } from '../db/db.module';
import { Sql, toVector } from '../db/db';
import { KeywordIntent } from '../llm/types';
export interface KeywordRow {
id: string;
canonical: string;
normalized: string;
aliases: string[];
intent: KeywordIntent;
usage_count: number;
}
export interface CandidateRow {
id: string;
canonical: string;
normalized: string;
cosine: number;
trg: number;
}
@Injectable()
export class KeywordRepository {
constructor(@Inject(PG) private readonly sql: Sql) {}
async findByNormalized(normalized: string, locale: string): Promise<KeywordRow | null> {
const rows = await this.sql<KeywordRow[]>`
SELECT id, canonical, normalized, aliases, intent, usage_count
FROM keyword
WHERE normalized = ${normalized} AND locale = ${locale}
LIMIT 1`;
return rows[0] ?? null;
}
/**
* 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다.
* 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다.
*/
async findDedupCandidates(
embedding: number[],
normalized: string,
locale: string,
limit: number,
): Promise<CandidateRow[]> {
const vec = toVector(embedding);
const rows = await this.sql<CandidateRow[]>`
(
SELECT id, canonical, normalized,
1 - (embedding <=> ${vec}::vector) AS cosine,
similarity(normalized, ${normalized}) AS trg
FROM keyword
WHERE locale = ${locale}
AND embedding IS NOT NULL
AND normalized % ${normalized}
ORDER BY trg DESC
LIMIT ${limit}
)
UNION ALL
(
SELECT id, canonical, normalized,
1 - (embedding <=> ${vec}::vector) AS cosine,
0::real AS trg
FROM keyword
WHERE locale = ${locale}
AND embedding IS NOT NULL
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}
)`;
const best = new Map<string, CandidateRow>();
for (const r of rows) {
const prev = best.get(r.id);
if (!prev || r.trg > prev.trg) best.set(r.id, { ...r, cosine: Number(r.cosine), trg: Number(r.trg) });
}
return [...best.values()].sort((a, b) => b.cosine - a.cosine);
}
async insert(input: {
canonical: string;
normalized: string;
locale: string;
intent: KeywordIntent;
embedding: number[];
industryId: string | null;
regionId: string | null;
}): Promise<KeywordRow> {
const rows = await this.sql<KeywordRow[]>`
INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count)
VALUES (${input.canonical}, ${input.normalized}, ${input.locale}, ${input.intent},
${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0)
ON CONFLICT (normalized, locale) DO UPDATE SET updated_at = now()
RETURNING id, canonical, normalized, aliases, intent, usage_count`;
return rows[0];
}
/** 표기 변형을 기존 키워드에 흡수 (롱테일 검색어 보존) */
async absorbAlias(keywordId: string, alias: string): Promise<void> {
await this.sql`
UPDATE keyword
SET aliases = (
SELECT ARRAY(SELECT DISTINCT unnest(aliases || ARRAY[${alias}]::text[]))
),
updated_at = now()
WHERE id = ${keywordId}
AND NOT (${alias} = ANY(aliases))
AND canonical <> ${alias}`;
}
async bumpUsage(keywordId: string): Promise<void> {
await this.sql`
UPDATE keyword SET usage_count = usage_count + 1, updated_at = now() WHERE id = ${keywordId}`;
}
async searchByVector(embedding: number[], locale: string, limit: number) {
const vec = toVector(embedding);
return this.sql<Array<{ id: string; canonical: string; intent: string; usage_count: number; score: number }>>`
SELECT id, canonical, intent, usage_count, 1 - (embedding <=> ${vec}::vector) AS score
FROM keyword
WHERE locale = ${locale} AND embedding IS NOT NULL
ORDER BY embedding <=> ${vec}::vector
LIMIT ${limit}`;
}
}