발행이 SiteOntology 를 부르는데 서버는 따로 띄워야 했다. 실측(2026-09-14): 서버가 없으면 `[seo] SiteOntology 실패 — 키워드 없이 발행: ConnectError` 로 빌드는 성공하고 메타만 빈다 — 화면으로는 안 보이는 종류다. 한 벌로 묶어 "코드는 올라갔는데 서버가 없는" 상태를 없앤다. - ontology/: gitea.o2o.kr/Web4ai/o2o-site-ontology 를 이 레포로 편입(그 원격은 그대로 남는다) - ontology/Dockerfile(신규): 베이스는 node:22-slim. alpine 은 임베딩 런타임(onnxruntime)이 musl 바이너리를 안 줘서 적재가 ERR_DLOPEN_FAILED 로 죽는다 — 빌드는 성공하고 실행에서만 터진다 - docker-compose.yml: ontology · ontology-postgres(pgvector) · ontology-redis 추가. 자체 DB 를 쓰는 이유는 pgvector 확장 때문이다 — web4ai_db 를 남의 서비스 확장에 묶지 않는다 - 임베딩 모델(120MB)은 이미지에 굽지 않고 볼륨(ontology-model)에 남긴다 - 컨테이너끼리는 `http://ontology:3100` 으로 만난다. `.env` 의 127.0.0.1 은 컨테이너 자기 자신이라 안 닿는다 검증: 3개 기동 · 백엔드 컨테이너에서 ontology:3100/demo HTTP 200 · 마이그레이션·시드 완료 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
9.9 KiB
TypeScript
254 lines
9.9 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import { Sql, toVector } from '../db/db';
|
|
import { PG } from '../db/db.module';
|
|
import { EmbeddingProvider } from '../embedding/types';
|
|
import { normalizeKeyword } from '../keywords/normalize';
|
|
import { MerchantWithTaxonomy } from '../merchants/merchants.service';
|
|
import {
|
|
AreaGroup, Lane, MerchantFacts, buildLanes, checkAmenity, detectAreaGroup,
|
|
keywordAreaGroup, normalizeAmenities, violatesCapacity,
|
|
} from './match.rules';
|
|
|
|
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
|
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
|
|
const RRF_K = 20;
|
|
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
|
|
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
|
|
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
|
|
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
|
|
|
|
interface Hit {
|
|
id: string; canonical: string; intent: string; kind: string;
|
|
category: string | null; aliases: string[]; score: number;
|
|
}
|
|
|
|
export interface MatchRow extends Hit {
|
|
rrf: number;
|
|
status: 'ok' | 'hold';
|
|
holdReason?: string;
|
|
lanes: Array<{ key: string; label: string; rank: number; score: number }>;
|
|
linked: boolean;
|
|
}
|
|
|
|
@Injectable()
|
|
export class MatchService {
|
|
constructor(
|
|
@Inject(PG) private readonly sql: Sql,
|
|
private readonly embedder: EmbeddingProvider,
|
|
) {}
|
|
|
|
/** 속성별 서브 질의 → 가중 RRF 융합 → 사실 기반 필터 */
|
|
async fusion(rawQuery: string, limit: number) {
|
|
const query = rawQuery.trim();
|
|
const merchant = await this.resolveMerchant(query);
|
|
const facts = merchant ? toFacts(merchant) : null;
|
|
|
|
const lanes: Lane[] = facts
|
|
? buildLanes(facts)
|
|
: [{ key: 'free', label: '입력문', weight: 1.0, text: query }];
|
|
|
|
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
|
|
|
// 레인별 검색.
|
|
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
|
|
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
|
|
const perLane = await Promise.all(
|
|
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
|
|
);
|
|
|
|
// 가중 RRF 융합
|
|
const acc = new Map<string, { hit: Hit; rrf: number; lanes: MatchRow['lanes'] }>();
|
|
perLane.forEach((hits, li) => {
|
|
const lane = lanes[li];
|
|
hits.forEach((hit, idx) => {
|
|
if (hit.score < LANE_FLOOR) return;
|
|
const rank = idx + 1;
|
|
const contrib = lane.weight / (RRF_K + rank);
|
|
const cur = acc.get(hit.id) ?? { hit, rrf: 0, lanes: [] };
|
|
cur.rrf += contrib;
|
|
cur.lanes.push({ key: lane.key, label: lane.label, rank, score: hit.score });
|
|
if (hit.score > cur.hit.score) cur.hit = hit;
|
|
acc.set(hit.id, cur);
|
|
});
|
|
});
|
|
|
|
// 사실 기반 필터
|
|
const kept: MatchRow[] = [];
|
|
const excluded: Array<{ canonical: string; reason: string }> = [];
|
|
for (const { hit, rrf, lanes: ls } of acc.values()) {
|
|
if (facts) {
|
|
if (violatesCapacity(hit.canonical, facts.capacityMax)) {
|
|
excluded.push({ canonical: hit.canonical, reason: `최대 ${facts.capacityMax}인 — 단체 키워드` });
|
|
continue;
|
|
}
|
|
const kwArea = keywordAreaGroup(hit.canonical);
|
|
if (kwArea && facts.areaGroup && kwArea !== facts.areaGroup) {
|
|
excluded.push({ canonical: hit.canonical, reason: `권역 불일치 — ${kwArea} (업체는 ${facts.areaGroup})` });
|
|
continue;
|
|
}
|
|
const am = checkAmenity(hit.canonical, facts);
|
|
if (!am.ok && !am.hold) {
|
|
excluded.push({ canonical: hit.canonical, reason: `미보유 시설 — ${am.amenity}` });
|
|
continue;
|
|
}
|
|
kept.push({
|
|
...hit, rrf,
|
|
lanes: ls.sort((a, b) => a.rank - b.rank),
|
|
status: am.ok ? 'ok' : 'hold',
|
|
holdReason: am.ok ? undefined : `${am.amenity} 미확인 — 사업자 확인 필요`,
|
|
linked: false,
|
|
});
|
|
} else {
|
|
kept.push({ ...hit, rrf, lanes: ls.sort((a, b) => a.rank - b.rank), status: 'ok', linked: false });
|
|
}
|
|
}
|
|
|
|
kept.sort((a, b) => b.rrf - a.rrf);
|
|
const top = kept.slice(0, limit);
|
|
|
|
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
|
|
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
|
|
const keptById = new Map(kept.map((k) => [k.id, k]));
|
|
const byLane = lanes.map((lane, li) => ({
|
|
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
|
|
items: perLane[li]
|
|
.map((h) => keptById.get(h.id))
|
|
.filter((x): x is MatchRow => Boolean(x))
|
|
.slice(0, 8),
|
|
}));
|
|
|
|
await this.markLinked([...top, ...byLane.flatMap((l) => l.items)], merchant?.id ?? null);
|
|
|
|
return {
|
|
mode: 'fusion' as const,
|
|
input: query,
|
|
resolved: merchant ? publicMerchant(merchant) : null,
|
|
facts: facts && {
|
|
areaGroup: facts.areaGroup,
|
|
capacityMax: facts.capacityMax,
|
|
amenities: [...facts.amenities],
|
|
unverified: [...facts.unverified],
|
|
},
|
|
lanes: lanes.map((l, i) => ({
|
|
...l, top: perLane[i][0]?.canonical ?? null, topScore: perLane[i][0]?.score ?? null,
|
|
})),
|
|
embeddingProvider: this.embedder.name,
|
|
total: await this.dictionarySize(),
|
|
matches: top,
|
|
byLane,
|
|
excluded: excluded.slice(0, 40),
|
|
excludedTotal: excluded.length,
|
|
};
|
|
}
|
|
|
|
private async laneSearch(
|
|
embedding: number[], limit: number, industryId: string | null,
|
|
): Promise<Hit[]> {
|
|
const vec = toVector(embedding);
|
|
const rows = await this.sql<Hit[]>`
|
|
SELECT id, canonical, intent, kind, category, aliases,
|
|
1 - (embedding <=> ${vec}::vector) AS score
|
|
FROM keyword
|
|
WHERE embedding IS NOT NULL
|
|
AND source = ANY(${MATCH_SOURCES})
|
|
AND (${industryId}::text IS NULL OR industry_id IS NULL OR industry_id = ${industryId})
|
|
ORDER BY embedding <=> ${vec}::vector
|
|
LIMIT ${limit}`;
|
|
return rows.map((r) => ({ ...r, score: Number(r.score) }));
|
|
}
|
|
|
|
private async markLinked(rows: MatchRow[], merchantId: string | null) {
|
|
if (!merchantId || rows.length === 0) return;
|
|
const ids = rows.map((r) => r.id);
|
|
const linked = await this.sql<Array<{ keyword_id: string }>>`
|
|
SELECT keyword_id FROM merchant_keyword
|
|
WHERE merchant_id = ${merchantId} AND keyword_id = ANY(${ids}::uuid[])`;
|
|
const set = new Set(linked.map((l) => l.keyword_id));
|
|
for (const r of rows) r.linked = set.has(r.id);
|
|
}
|
|
|
|
async resolveMerchant(query: string) {
|
|
const norm = normalizeKeyword(query);
|
|
if (!norm) return null;
|
|
const rows = await this.sql<Array<MerchantWithTaxonomy & { sim: number }>>`
|
|
SELECT m.*,
|
|
i.name AS industry_name, i.path::text AS industry_path,
|
|
r.name AS region_name, r.path::text AS region_path,
|
|
similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) AS sim
|
|
FROM merchant m
|
|
LEFT JOIN industry i ON i.id = m.industry_id
|
|
LEFT JOIN region r ON r.id = m.region_id
|
|
WHERE regexp_replace(lower(m.name), '\\s', '', 'g') = ${norm}
|
|
OR m.external_id = ${query}
|
|
OR similarity(regexp_replace(lower(m.name), '\\s', '', 'g'), ${norm}) >= 0.45
|
|
ORDER BY sim DESC NULLS LAST
|
|
LIMIT 1`;
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async dictionarySize() {
|
|
const [row] = await this.sql<Array<{ n: number }>>`
|
|
SELECT count(*)::int AS n FROM keyword
|
|
WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`;
|
|
return row?.n ?? 0;
|
|
}
|
|
}
|
|
|
|
function str(v: unknown): string[] {
|
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
|
}
|
|
|
|
function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
|
|
const p = (m.profile ?? {}) as Record<string, unknown>;
|
|
const features = str(p['features']);
|
|
const services = str(p['services']);
|
|
const nearby = str(p['nearby']);
|
|
const address = typeof p['address'] === 'string' ? p['address'] : null;
|
|
const cap = p['capacity'] as { max?: number } | undefined;
|
|
|
|
const areaSource = [address ?? '', ...nearby, m.description].join(' ');
|
|
const areaGroup: AreaGroup | null = detectAreaGroup(areaSource);
|
|
|
|
return {
|
|
name: m.name,
|
|
region: m.region_name,
|
|
industry: m.industry_name,
|
|
description: m.description,
|
|
address,
|
|
areaGroup,
|
|
capacityMax: typeof cap?.max === 'number' ? cap.max : null,
|
|
services, features,
|
|
audiences: str(p['audiences']),
|
|
nearby,
|
|
amenities: normalizeAmenities([...features, ...services]),
|
|
unverified: new Set(str(p['unverified'])),
|
|
signals: collectSignals(p),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 고객 언어 신호를 모은다.
|
|
* hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등
|
|
* reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계
|
|
* 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다.
|
|
*/
|
|
function collectSignals(p: Record<string, unknown>): string[] {
|
|
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
|
|
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
|
|
const reviews = raw
|
|
.filter((r): r is { term: string; count: number } =>
|
|
Boolean(r) && typeof (r as any).term === 'string')
|
|
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
|
|
.map((r) => r.term.trim())
|
|
.filter(Boolean);
|
|
return [...new Set([...reviews, ...tags])];
|
|
}
|
|
|
|
function publicMerchant(m: MerchantWithTaxonomy) {
|
|
return {
|
|
id: m.id, externalId: m.external_id, name: m.name,
|
|
region: m.region_name, industry: m.industry_name,
|
|
description: m.description, siteUrl: m.site_url, profile: m.profile ?? {},
|
|
};
|
|
}
|