import { Injectable, Logger } from '@nestjs/common'; import { env } from '../config/env'; import { KeywordIntent } from '../llm/types'; import { KeywordRepository } from './keyword.repository'; import { canonicalizeKeyword, isBanned, normalizeKeyword } from './normalize'; export type DedupAction = | 'created' // 새 키워드 | 'matched_exact' // 1단계: 정규화 해시 일치 | 'matched_trigram' // 2단계: 표기 변형/오타 | 'matched_vector' // 3단계: 의미 중복 → alias 흡수 | 'rejected_banned'; // 금칙어 export interface DedupResult { action: DedupAction; keywordId: string | null; canonical: string; matchedTo?: string; similarity?: number; } export interface ResolveInput { raw: string; intent: KeywordIntent; embedding: number[]; locale: string; industryId: string | null; regionId: string | null; } /** * 4단계 계단식 중복제거. * 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다. */ @Injectable() export class DedupService { private readonly logger = new Logger(DedupService.name); constructor(private readonly repo: KeywordRepository) {} async resolve(input: ResolveInput): Promise { const canonical = canonicalizeKeyword(input.raw); const normalized = normalizeKeyword(input.raw); // 0단계 — 금칙어/과장광고 차단 if (!normalized || isBanned(canonical)) { return { action: 'rejected_banned', keywordId: null, canonical }; } // 1단계 — 정규화 완전 일치 (공백/구두점 차이 흡수) const exact = await this.repo.findByNormalized(normalized, input.locale); if (exact) { await this.repo.absorbAlias(exact.id, canonical); return { action: 'matched_exact', keywordId: exact.id, canonical: exact.canonical, matchedTo: exact.canonical, similarity: 1, }; } // 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정 // // 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다. // 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936, // '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다. // 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다. // 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다. const candidates = await this.repo.findDedupCandidates( input.embedding, normalized, input.locale, env.dedup.candidateLimit, ); const trigramHit = candidates.find((c) => c.trg >= env.dedup.trigramThreshold); if (trigramHit) { await this.repo.absorbAlias(trigramHit.id, canonical); return { action: 'matched_trigram', keywordId: trigramHit.id, canonical: trigramHit.canonical, matchedTo: trigramHit.canonical, similarity: trigramHit.trg, }; } const best = candidates[0]; if (best && best.cosine >= env.dedup.cosineThreshold) { await this.repo.absorbAlias(best.id, canonical); return { action: 'matched_vector', keywordId: best.id, canonical: best.canonical, matchedTo: best.canonical, similarity: best.cosine, }; } // 4단계 — 신규 등록 const created = await this.repo.insert({ canonical, normalized, locale: input.locale, intent: input.intent, embedding: input.embedding, industryId: input.industryId, regionId: input.regionId, }); return { action: 'created', keywordId: created.id, canonical: created.canonical }; } }