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 { const rows = await this.sql` 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 { const vec = toVector(embedding); const rows = await this.sql` ( 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(); 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 { const rows = await this.sql` 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 { 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 { 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>` 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}`; } }