o2o-site-AEO/ontology/src/serving/match.service.ts
Mina Choi 01098835e9 [chore] docker-compose,ontology: 온톨로지를 이 레포로 들여 compose 한 벌로 띄운다 — 앱 Dockerfile 신설
발행이 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>
2026-09-14 17:41:03 +09:00

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 ?? {},
};
}