발행된 사이트에 업체별 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>
125 lines
4.1 KiB
TypeScript
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}`;
|
|
}
|
|
}
|