From 847943a8bc492ff2e75c05c177e2b1bc4cd3af5b Mon Sep 17 00:00:00 2001 From: hbyang Date: Thu, 1 Oct 2026 13:49:37 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20ontology:=20=EC=83=88=20=EC=88=99?= =?UTF-8?q?=EC=86=8C=20=ED=82=A4=EC=9B=8C=EB=93=9C=EB=A5=BC=20OpenAI=20?= =?UTF-8?q?=EB=A1=9C=20=EC=8C=93=EA=B3=A0=20=EB=A7=A4=EC=B9=AD=EC=97=90=20?= =?UTF-8?q?=EC=93=B4=EB=8B=A4=20=E2=80=94=20=ED=98=B8=ED=85=94=20=EC=97=85?= =?UTF-8?q?=EC=A2=85=20=C2=B7=20=EC=88=99=EC=86=8C=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사전이 고정 데이터셋뿐이라 사전에 없는 숙소는 새 단어를 받지 못했고, 생성분은 MATCH_SOURCES 가 llm 을 빼서 만들어도 추천에 안 나왔다. 숙소 검색은 가까운 키워드를 사전 전체(7천)에서 뽑아 상위가 아무 숙소에도 안 붙은 단어로 차서 `선유도 독채` 가 0건이었다. - merchants.controller: 처음 보는 업체·기한 지난 업체만 생성, sync 는 처음 보는 업체만 기다림 — 재빌드마다 불리는 창구라 조건이 없으면 저장마다 요금. 동기 생성 실패는 200 + generation.failed - generation·dedup·keyword.repository: mock 생성분은 source='mock' — compose 기본이 mock 이라 키 없는 환경의 가짜 단어가 매칭 후보가 되지 않게 - match.service: llm 출처를 후보에 넣음(brand 는 그 업체 것만), 업종은 뿌리(stay)로 좁힘 — 새 호텔은 호텔 사전이 비어 잎으로 좁히면 후보 0건 - openai.provider: 미확인 시설·다른 숙박 유형어·가격 주장 금지 - drizzle/0002 · seed: stay.hotel — 시드만 두면 떠 있는 DB 에서 외래키 위반 500 - serving.service: 숙소에 연결된 키워드만 비교, 검색어가 지역을 말하면 그 지역 숙소만 — e5-small 은 지역이 틀려도 0.85(`여수 호텔` → 군산 호텔 0.848)라 임계값으로는 못 가른다 tsc 통과. 로컬 :3101 + 실 OpenAI(가짜 숙소 2곳): 첫 발행 7초 · 키워드 12 생성 · 재발행 fresh, 검색 7건(`은파호수공원 근처 호텔`·`선유도 독채`·`군산시내호텔` 적중, `여수 호텔` 0건) 확인 후 테스트 데이터 삭제 Co-Authored-By: Claude Opus 5.5 (1M context) --- ontology/README.md | 10 +++-- ontology/drizzle/0002_industry_hotel.sql | 4 ++ ontology/src/db/seed.ts | 1 + ontology/src/generation/generation.service.ts | 6 +++ ontology/src/keywords/dedup.service.ts | 3 ++ ontology/src/keywords/keyword.repository.ts | 5 ++- ontology/src/llm/openai.provider.ts | 4 ++ .../src/merchants/merchants.controller.ts | 23 +++++++++--- ontology/src/merchants/merchants.service.ts | 6 +++ ontology/src/serving/match.rules.ts | 2 +- ontology/src/serving/match.service.ts | 33 ++++++++++++----- ontology/src/serving/serving.service.ts | 37 +++++++------------ 12 files changed, 90 insertions(+), 44 deletions(-) create mode 100644 ontology/drizzle/0002_industry_hotel.sql diff --git a/ontology/README.md b/ontology/README.md index ee479e7..18d4a9d 100644 --- a/ontology/README.md +++ b/ontology/README.md @@ -2,7 +2,9 @@ o2o-site-AEO 가 발행한 사이트에 **업체별 SEO/AEO 키워드**를 제공하는 온톨로지 서비스. -- **고정 데이터셋 1회 적재** 정책 — 주기 수집 없음 (`data/gunsan-pension-keywords.json`, 1,000건) +- 사전은 **고정 데이터셋 + 업체별 LLM 생성분**이다. 처음 보는 숙소가 발행되면 OpenAI 로 키워드를 만들어 + 쌓고(`source='llm'`), 이후 매칭 후보가 된다. mock 이 만든 단어는 `source='mock'` 으로 남아 후보가 되지 않는다 +- 업종은 숙박(`stay`) 아래 펜션(`stay.pension`)·호텔(`stay.hotel`). 매칭·검색은 뿌리(`stay`)로 묶는다 - 로컬 임베딩(`multilingual-e5-small`, 384차원)으로 pgvector 에 적재 후 의미 검색 - 업체명 또는 자연어 문장 → 사전에서 잘 맞는 키워드를 골라주는 **매칭 API + 데모 콘솔** - 어휘 단계 중복제거는 자동, 벡터 근접쌍은 자동 병합하지 않고 검토 목록으로만 @@ -245,13 +247,14 @@ npm run dataset:ingest && npm run dataset:ingest-nationwide | 메서드 | 경로 | 용도 | |---|---|---| | `GET` | `/health` | 헬스체크 | -| `POST` | `/v1/merchants/publish` | **사이트 발행 웹훅** — 업체 upsert + 생성 예약 (`sync:true` 면 동기 실행) | +| `POST` | `/v1/merchants/publish` | **사이트 발행 웹훅** — 업체 upsert + 생성. 처음 보는 업체·`REFRESH_INTERVAL_DAYS` 지난 업체만 생성하고, `sync:true` 는 처음 보는 업체에만 동기로 기다린다. 동기 생성이 실패해도 200 (`generation.failed`) | | `POST` | `/v1/merchants/:id/generate?sync=true` | 수동 재생성 | | `GET` | `/v1/merchants` `/v1/merchants/:id` | 조회 | | `GET` | `/v1/sites/:id/seo?limit=20` | **발행 사이트가 렌더링 시 호출** — title/description/keywords/tags | | `GET` | `/v1/sites/:id/aeo?limit=10` | 답변엔진용 topics/FAQ/structuredDataHints | | `POST` | `/v1/match` | **업체명 또는 문장 → 사전에서 잘 맞는 키워드.** `mode=fusion`(기본) / `single`(통짜, 비교용) | | `POST` | `/v1/keywords/search` | 의미 기반 키워드 검색 (어드민) | +| `POST` | `/v1/lodgings/search` | **검색어 → 맞는 숙소와 각 숙소의 SEO 메타.** 숙소에 연결된 키워드와의 유사도(≥0.87, 표기 변형은 aliases) + 지역 이름. 검색어가 지역을 말하면 그 지역 숙소만 | | `GET` | `/demo` | 매칭 콘솔 (로컬 확인용) | | `POST` | `/v1/sites/:id/performance` | Search Console·유입 로그 피드백 → 저성과 키워드 강등 | @@ -401,7 +404,8 @@ curl -s -X POST http://localhost:3100/v1/match \ ## 생성 주기 -- **발행 즉시** — `/v1/merchants/publish` 가 BullMQ 에 적재 (60초 dedupe 창) +- **첫 발행** — 처음 보는 업체는 `/v1/merchants/publish` 가 동기로 생성한다 (o2o-site-AEO 가 `sync:true` 로 부른다) +- **기한 지난 업체의 발행** — BullMQ 에 적재 (60초 dedupe 창). 기한 안이면 생성하지 않는다(`generation: 'fresh'`) - **주기 리프레시** — 매일 03:00 크론이 `REFRESH_INTERVAL_DAYS`(기본 30일) 지난 업체를 적재 - **성과 기반** — 노출 100회 이상 & CTR < 0.2% 인 키워드는 `demoted` 로 강등, 다음 사이클에서 대체 diff --git a/ontology/drizzle/0002_industry_hotel.sql b/ontology/drizzle/0002_industry_hotel.sql new file mode 100644 index 0000000..94d619d --- /dev/null +++ b/ontology/drizzle/0002_industry_hotel.sql @@ -0,0 +1,4 @@ +-- 호텔 업종. o2o-site-AEO 가 외부 분류에 "호텔" 이 있는 숙소를 stay.hotel 로 보낸다. +-- ★ 시드에만 두면 이미 떠 있는 DB 에는 없고, merchant.industry_id 외래키 위반으로 publish 가 500 이 된다. +INSERT INTO industry (id, path, name) VALUES ('stay.hotel', 'stay.hotel', '호텔') +ON CONFLICT (id) DO NOTHING; diff --git a/ontology/src/db/seed.ts b/ontology/src/db/seed.ts index e017f0c..19ed8ba 100644 --- a/ontology/src/db/seed.ts +++ b/ontology/src/db/seed.ts @@ -10,6 +10,7 @@ const industries = [ ['health.dental', 'health.dental', '치과'], ['stay', 'stay', '숙박'], ['stay.pension', 'stay.pension', '펜션'], + ['stay.hotel', 'stay.hotel', '호텔'], ]; const regions = [ diff --git a/ontology/src/generation/generation.service.ts b/ontology/src/generation/generation.service.ts index b866415..33eff1f 100644 --- a/ontology/src/generation/generation.service.ts +++ b/ontology/src/generation/generation.service.ts @@ -103,6 +103,7 @@ export class GenerationService { locale: 'ko-KR', industryId: merchant.industry_id, regionId: merchant.region_id, + source: keywordSource(this.llm.name), }); stats.details.push({ @@ -214,3 +215,8 @@ export class GenerationService { return created; } } + +// mock 이 만든 단어를 'llm' 으로 두면 매칭 후보가 되어 가짜 단어가 발행본에 나간다. +function keywordSource(provider: string): string { + return provider === 'mock' ? 'mock' : 'llm'; +} diff --git a/ontology/src/keywords/dedup.service.ts b/ontology/src/keywords/dedup.service.ts index b2ddf04..01772af 100644 --- a/ontology/src/keywords/dedup.service.ts +++ b/ontology/src/keywords/dedup.service.ts @@ -26,6 +26,8 @@ export interface ResolveInput { locale: string; industryId: string | null; regionId: string | null; + /** 새로 등록될 때 keyword.source — 매칭 후보 여부가 이 값으로 갈린다 */ + source: string; } /** 4단계 계단식 중복제거. */ @@ -98,6 +100,7 @@ export class DedupService { embedding: input.embedding, industryId: input.industryId, regionId: input.regionId, + source: input.source, }); return { action: 'created', keywordId: created.id, canonical: created.canonical }; } diff --git a/ontology/src/keywords/keyword.repository.ts b/ontology/src/keywords/keyword.repository.ts index b87952a..ade6957 100644 --- a/ontology/src/keywords/keyword.repository.ts +++ b/ontology/src/keywords/keyword.repository.ts @@ -81,11 +81,12 @@ export class KeywordRepository { embedding: number[]; industryId: string | null; regionId: string | null; + source: string; }): Promise { const rows = await this.sql` - INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count) + INSERT INTO keyword (canonical, normalized, locale, intent, embedding, industry_id, region_id, usage_count, source) VALUES (${input.canonical}, ${input.normalized}, ${input.locale}, ${input.intent}, - ${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0) + ${toVector(input.embedding)}::vector, ${input.industryId}, ${input.regionId}, 0, ${input.source}) ON CONFLICT (normalized, locale) DO UPDATE SET updated_at = now() RETURNING id, canonical, normalized, aliases, intent, usage_count`; return rows[0]; diff --git a/ontology/src/llm/openai.provider.ts b/ontology/src/llm/openai.provider.ts index 1b90db7..f6fbd56 100644 --- a/ontology/src/llm/openai.provider.ts +++ b/ontology/src/llm/openai.provider.ts @@ -92,6 +92,10 @@ const SYSTEM_PROMPT = `당신은 한국 로컬 비즈니스 SEO/AEO 전문가입 - 지역명 + 업종 + 의도어 조합을 적극 활용합니다. - 과장광고 표현(최고, 1위, 100%, 무조건, 완치 등)은 절대 사용하지 않습니다. - 제공된 "이미 보유한 키워드"와 의미가 겹치는 것은 생성하지 않습니다. +- 시설·전망·객실 유형은 "상세"의 features·nearby 에 있는 것만 씁니다. 없는 것을 지어내지 않습니다. +- "상세"의 unverified 에 있는 항목은 확인되지 않은 것이므로 키워드와 답변 어디에도 쓰지 않습니다. +- 숙박 유형어는 주어진 업종의 것만 씁니다. 업종이 호텔이면 '펜션'을, 펜션이면 '호텔'을 붙이지 않습니다. +- 가격·최저가·할인 같은 가격 주장은 쓰지 않습니다. - relevance 는 해당 업체와의 관련도를 0.0~1.0 으로 매깁니다. - 답변(answer)은 2~3문장, 업체 정보에 근거한 사실만 씁니다.`; diff --git a/ontology/src/merchants/merchants.controller.ts b/ontology/src/merchants/merchants.controller.ts index b0eed6f..a2036c7 100644 --- a/ontology/src/merchants/merchants.controller.ts +++ b/ontology/src/merchants/merchants.controller.ts @@ -1,10 +1,13 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Logger, Param, Post, Query } from '@nestjs/common'; +import { env } from '../config/env'; import { GenerationService } from '../generation/generation.service'; import { GenerationQueue } from '../generation/generation.queue'; import { MerchantsService, UpsertMerchantDto } from './merchants.service'; @Controller('v1/merchants') export class MerchantsController { + private readonly logger = new Logger(MerchantsController.name); + constructor( private readonly merchants: MerchantsService, private readonly generation: GenerationService, @@ -21,15 +24,25 @@ export class MerchantsController { return this.merchants.findWithTaxonomy(id); } - /** o2o-site-AEO 사이트 발행 웹훅: 업체 등록 + 키워드 생성 예약 */ + /** 발행 웹훅 — 재빌드마다 불리므로 처음 보는 업체·기한 지난 업체만 생성해 LLM 요금을 묶는다 */ @Post('publish') async publish(@Body() dto: UpsertMerchantDto & { generate?: boolean; sync?: boolean }) { const merchant = await this.merchants.upsert(dto); if (dto.generate === false) return { merchant, generation: 'skipped' }; + if (!this.merchants.isStale(merchant, env.generation.refreshIntervalDays)) { + return { merchant, generation: 'fresh' }; + } - if (dto.sync) { - const stats = await this.generation.runForMerchant(merchant.id, 'published'); - return { merchant, generation: stats }; + // 첫 발행부터 새 키워드가 실리도록 처음 보는 업체만 기다린다. 실패해도 사전 키워드로 발행은 나간다. + if (dto.sync && merchant.last_generated_at === null) { + try { + const stats = await this.generation.runForMerchant(merchant.id, 'published'); + return { merchant, generation: { ...stats, details: undefined } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`[${merchant.name}] 동기 생성 실패 — 사전 키워드로만 매칭된다: ${message}`); + return { merchant, generation: { failed: true, error: message } }; + } } const jobId = await this.queue.enqueue(merchant.id, 'published'); return { merchant, generation: { queued: true, jobId } }; diff --git a/ontology/src/merchants/merchants.service.ts b/ontology/src/merchants/merchants.service.ts index 1ca386f..cd6ffc4 100644 --- a/ontology/src/merchants/merchants.service.ts +++ b/ontology/src/merchants/merchants.service.ts @@ -83,6 +83,12 @@ export class MerchantsService { LIMIT ${limit}`; } + /** findStale 과 같은 기준을 행 하나에 — publish 가 생성 여부를 정할 때 쓴다 */ + isStale(merchant: Pick, intervalDays: number): boolean { + if (!merchant.last_generated_at) return true; + return Date.now() - new Date(merchant.last_generated_at).getTime() > intervalDays * 86_400_000; + } + async markGenerated(merchantId: string) { await this.sql`UPDATE merchant SET last_generated_at = now() WHERE id = ${merchantId}`; } diff --git a/ontology/src/serving/match.rules.ts b/ontology/src/serving/match.rules.ts index c7a491b..b92f0e1 100644 --- a/ontology/src/serving/match.rules.ts +++ b/ontology/src/serving/match.rules.ts @@ -114,7 +114,7 @@ export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerd } // ──────────────────────────────────────────── 서브 질의 빌더 -const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성']; +const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '호텔', '리조트', '글램핑', '카라반', '한옥', '민박', '감성']; const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/; /** 레인 설계 원칙 1. 레인끼리 겹치지 않게 한다. */ diff --git a/ontology/src/serving/match.service.ts b/ontology/src/serving/match.service.ts index 10df7b3..d0d197f 100644 --- a/ontology/src/serving/match.service.ts +++ b/ontology/src/serving/match.service.ts @@ -15,6 +15,8 @@ const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 // 매칭 후보로 인정하는 출처. const MATCH_SOURCES = ['dataset', 'nationwide', 'manual']; +// LLM 이 만든 키워드도 후보다. 단 intent=brand 는 남의 상호이므로 그 업체 것만 받는다. +const LLM_SOURCE = 'llm'; interface Hit { id: string; canonical: string; intent: string; kind: string; @@ -49,8 +51,9 @@ export class MatchService { const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query'); // 레인별 검색. + // 잎(stay.pension)이 아니라 뿌리(stay)로 좁힌다 — 펜션·호텔이 서로의 관련 키워드를 받고, 틀린 유형어는 호출측이 뺀다. const perLane = await Promise.all( - vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)), + vectors.map((v) => this.laneSearch(v, LANE_DEPTH, industryRoot(merchant?.industry_path), merchant?.id ?? null)), ); // 가중 RRF 융합 @@ -138,17 +141,22 @@ export class MatchService { } private async laneSearch( - embedding: number[], limit: number, industryId: string | null, + embedding: number[], limit: number, root: string | null, merchantId: string | null, ): Promise { const vec = toVector(embedding); const rows = await this.sql` - 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 + SELECT k.id, k.canonical, k.intent, k.kind, k.category, k.aliases, + 1 - (k.embedding <=> ${vec}::vector) AS score + FROM keyword k + LEFT JOIN industry i ON i.id = k.industry_id + WHERE k.embedding IS NOT NULL + AND (k.source = ANY(${MATCH_SOURCES}) + OR (k.source = ${LLM_SOURCE} + AND (k.intent <> 'brand' + OR EXISTS (SELECT 1 FROM merchant_keyword mk + WHERE mk.keyword_id = k.id AND mk.merchant_id = ${merchantId}::uuid)))) + AND (${root}::text IS NULL OR k.industry_id IS NULL OR i.path <@ ${root}::ltree) + ORDER BY k.embedding <=> ${vec}::vector LIMIT ${limit}`; return rows.map((r) => ({ ...r, score: Number(r.score) })); } @@ -185,11 +193,16 @@ export class MatchService { async dictionarySize() { const [row] = await this.sql>` SELECT count(*)::int AS n FROM keyword - WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`; + WHERE embedding IS NOT NULL AND (source = ANY(${MATCH_SOURCES}) OR source = ${LLM_SOURCE})`; return row?.n ?? 0; } } +/** 'stay.pension' → 'stay'. 업종이 없으면 null(좁히지 않는다) */ +export function industryRoot(path: string | null | undefined): string | null { + return path ? path.split('.')[0] : null; +} + function str(v: unknown): string[] { return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []; } diff --git a/ontology/src/serving/serving.service.ts b/ontology/src/serving/serving.service.ts index 5b25b54..2da743a 100644 --- a/ontology/src/serving/serving.service.ts +++ b/ontology/src/serving/serving.service.ts @@ -7,11 +7,11 @@ import { EmbeddingProvider } from '../embedding/types'; import { MerchantsService, MerchantWithTaxonomy } from '../merchants/merchants.service'; const LODGING_ROOT = 'stay'; -// 검색어 하나당 살펴볼 가까운 키워드 수. 한 숙소가 연결한 키워드는 15개 안팎이라 이 정도면 겹친다. +// 검색어 하나당 살펴볼 가까운 키워드 수(숙소에 연결된 키워드 중에서). const SEARCH_KEYWORD_DEPTH = 60; -// ★ 짧은 한글 키워드는 전혀 다른 말도 코사인이 높다(dedup.service 실측: '선유도 펜션' ↔ '새만금 펜션' = 0.936). -// 그래서 벡터 갈래는 이 값 이상만 "같은 뜻" 으로 치고, 지역 구분은 벡터가 아니라 위치 갈래에 맡긴다. -const SEARCH_FLOOR = 0.94; +// e5-small 에서 맞는 숙소는 0.87 이상, 동떨어진 숙소는 0.86 아래로 갈린다. 지역 구분은 이 값이 아니라 지역 이름으로 한다. +// ponytail: 고정 임계값 — 숙소가 늘어 경계가 흐려지면 1위 대비 상대 컷으로 바꾼다. +const SEARCH_FLOOR = 0.87; const SEARCH_SEO_KEYWORDS = 20; export interface SeoPayload { @@ -181,18 +181,7 @@ export class ServingService { return row?.n ?? 0; } - /** - * 검색어 → 그 말로 찾을 만한 숙소와 각 숙소의 SEO 메타. - * - * 두 갈래를 합친다. - * 키워드 — 검색어와 가까운 키워드(표기 변형은 aliases 로 흡수돼 있다)를 **가진** 숙소 - * 위치 — 검색어에 지역 이름이 들어 있으면 그 지역(하위 포함) 숙소 - * 위치가 맞는 숙소를 앞에 둔다. `군산 오션뷰` 로 찾는데 여수 오션뷰 숙소가 먼저 나오면 틀린 답이다. - * - * ★ 업종은 숙박 뿌리(stay.*)로 묶는다 — 펜션을 찾아도 관련 키워드를 가진 호텔이 함께 나온다(2026-09-30 지시). - * ★ 키워드 갈래는 merchant_keyword(생성 때 연결된 것)만 본다. /v1/match 의 추천은 저장되지 않으므로 - * 생성이 한 번도 안 돈 숙소는 위치 갈래로만 잡힌다. - */ + /** 검색어 → 맞는 숙소(숙박 전체)와 SEO 메타. 키워드 갈래 + 지역 갈래, 지역을 말하면 그 지역만 */ async searchLodgings(rawQuery: string, limit: number) { const query = rawQuery.trim(); if (!query) return { input: query, results: [] }; @@ -206,10 +195,11 @@ export class ServingService { region_hit: boolean; score: number | null; matched: string[] | null; }>>` WITH near AS ( - SELECT id, 1 - (embedding <=> ${vec}::vector) AS score - FROM keyword - WHERE embedding IS NOT NULL - ORDER BY embedding <=> ${vec}::vector + SELECT k.id, 1 - (k.embedding <=> ${vec}::vector) AS score + FROM keyword k + WHERE k.embedding IS NOT NULL + AND EXISTS (SELECT 1 FROM merchant_keyword mk WHERE mk.keyword_id = k.id AND mk.status = 'active') + ORDER BY k.embedding <=> ${vec}::vector LIMIT ${SEARCH_KEYWORD_DEPTH} ), hits AS ( SELECT id, score FROM near WHERE score >= ${SEARCH_FLOOR} @@ -221,7 +211,7 @@ export class ServingService { SELECT m.external_id, r.name AS region, i.name AS industry, COALESCE(bool_or(r.path <@ p.path), false) AS region_hit, max(h.score) AS score, - array_remove(array_agg(DISTINCT k.canonical), NULL) AS matched + array_remove(array_agg(k.canonical ORDER BY h.score DESC), NULL) AS matched FROM merchant m JOIN industry i ON i.id = m.industry_id AND i.path <@ ${LODGING_ROOT}::ltree LEFT JOIN region r ON r.id = m.region_id @@ -230,7 +220,8 @@ export class ServingService { LEFT JOIN hits h ON h.id = mk.keyword_id LEFT JOIN keyword k ON k.id = h.id GROUP BY m.id, r.name, i.name - HAVING max(h.score) IS NOT NULL OR bool_or(p.path IS NOT NULL) + HAVING (max(h.score) IS NOT NULL OR bool_or(p.path IS NOT NULL)) + AND (NOT EXISTS (SELECT 1 FROM places) OR bool_or(p.path IS NOT NULL)) ORDER BY region_hit DESC, score DESC NULLS LAST, m.name LIMIT ${limit}`; @@ -241,7 +232,7 @@ export class ServingService { industry: r.industry, regionMatched: r.region_hit, score: r.score === null ? null : Number(r.score), - matchedKeywords: r.matched ?? [], + matchedKeywords: [...new Set(r.matched ?? [])], seo: await this.seo(r.external_id, SEARCH_SEO_KEYWORDS), }); }