From 25f2cc871d0b3b315be78d71f2804d7540b0ed2c Mon Sep 17 00:00:00 2001 From: hbyang Date: Fri, 11 Sep 2026 14:09:36 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EB=A8=B8=EB=AD=84?= =?UTF-8?q?=20=EB=A7=A4=EC=B9=AD=20=ED=92=88=EC=A7=88=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=204=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ② 사전 보강 — 실측으로 찾은 어휘 공백을 메움 - 인원: 2인·3인 추가 (4/6/10/20인만 있어 기준 2인 업체가 노릴 키워드가 없었다) - 단독 명사형: 군산 독채 / 스테이 / 풀빌라 (조합형만 있어 단독 검색을 놓쳤다) - 분위기 11종(감성·조용한·사진찍기 좋은·인생샷 …), 여행형태 9종(뚜벅이·1박2일 …) - scripts/import-related.ts — 검색광고 키워드도구 내려받기 병합 (파일 임포터) ③ 사전 오염 정리 - source='llm' 27건 삭제 (강남 미용실·해운대 한식당이 군산 펜션 사전에 있었다) - 매칭 후보를 업체 업종으로 한정 + source IN ('dataset','manual') 만 조회 - 적재 시 데이터셋에서 빠진 행 삭제 — upsert 만 하면 재빌드마다 누적된다 (974 → 1072) ④ 고객 언어 레인 (w=0.9) - hashtags + reviewSignals(원문 아닌 빈도 집계) 를 별도 레인으로 - 사업자 표현보다 검색어에 가까우므로 유형 다음으로 높게 잡음 - 스테이머뭄 데이터는 아직 없음 — 인스타는 로그인 월이라 스크래퍼가 채워야 함 ⑤ 레인 토큰 중복 제거 - '신흥동' 과 '신흥동 일본식가옥' 이 별개 원소라 같은 낱말이 두 번 실렸다 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 43 +- data/gunsan-pension-keywords.json | 1372 ++++++++++++++--------------- data/related-keywords.sample.csv | 8 + package.json | 4 +- scripts/build-dataset.mjs | 29 +- scripts/import-related.ts | 90 ++ scripts/ingest-dataset.ts | 17 +- scripts/purge-nondataset.ts | 35 + src/serving/match.rules.ts | 20 +- src/serving/match.service.ts | 36 +- 10 files changed, 958 insertions(+), 696 deletions(-) create mode 100644 data/related-keywords.sample.csv create mode 100644 scripts/import-related.ts create mode 100644 scripts/purge-nondataset.ts diff --git a/README.md b/README.md index 00a1ccd..6710356 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,20 @@ OPENAI_EMBEDDING_MODEL=text-embedding-3-small 적재 결과: 1,000건 → 어휘 중복 27건 병합, 금칙어 2건 차단 → **971건 적재**. `npm run dataset:build` 로 다시 만들고 `npm run dataset:ingest` 로 다시 넣는다. -재적재는 `ON CONFLICT (normalized, locale) DO UPDATE` 라 몇 번을 돌려도 971건을 유지한다. +적재는 upsert 이고, **데이터셋에서 빠진 행은 같이 지운다** — 안 그러면 재빌드할 때마다 +이전 판본 잔여가 쌓여 사전이 계속 커진다 (실제로 974건 데이터셋인데 사전이 1072건까지 불었다). + +| 명령 | 용도 | +|---|---| +| `npm run dataset:build` | 데이터셋 생성 | +| `npm run dataset:ingest` | 임베딩 + 적재 + 잔여 정리 | +| `npm run dataset:purge` | 큐레이션 외 출처(`llm` 등) 제거. `--apply` 로 실행 | +| `npm run dataset:import-related` | 검색광고 키워드도구 내려받기(CSV/JSON) 병합. `--apply` 로 실행 | + +`dataset:import-related` 는 API 클라이언트가 아니라 파일 임포터다. 검색광고 API 는 +계정·HMAC 서명이 필요해 자격증명 없이 검증할 수 없다. 키워드도구에서 CSV 를 내려받아 +`data/related-keywords.sample.csv` 형식으로 두면 그대로 병합된다 — +나중에 API 를 붙여도 이 임포터를 재사용한다. ## 임베딩 임계값 — 실측으로 정정한 부분 @@ -202,6 +215,34 @@ curl 'http://localhost:3100/v1/sites/site-1001/seo?limit=8' - **RRF 상수는 관례값 60 이 아니라 20.** 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 올라온다. 20 이면 2.9배로 벌어진다. +#### 레인 구성 + +| 레인 | 가중치 | 출처 | 비고 | +|---|---|---|---| +| 유형 | 1.0 | 지역 + 업종 + 숙소유형 | 앵커. 주력 키워드가 여기서 나온다 | +| **고객언어** | **0.9** | `reviewSignals` 빈출어 + `hashtags` | 사업자 표현보다 검색어에 가깝다 | +| 위치 | 0.7 | 권역 + 행정동 + 인근 랜드마크 | | +| 동반자 | 0.6 | `audiences` | | +| 시설 | 0.6 | 정규화된 `amenities` | | + +레인 텍스트는 **낱말 단위로 중복을 제거**한다. 문자열 단위 Set 만으로는 `신흥동` 과 +`신흥동 일본식가옥` 이 서로 다른 원소라 같은 낱말이 두 번 실리고, 그쪽으로 레인이 쏠린다. + +후보 풀은 **업체 업종으로 한정**하고 `source IN ('dataset','manual')` 만 본다. +사전 전체를 뒤지면 다른 업종 키워드(`강남 미용실` 등)가 후보에 섞인다. + +#### 고객 언어 신호 + +리뷰 **원문은 받지 않는다** (저작권·개인정보). 빈도 집계만 받는다. + +```json +"hashtags": ["#군산감성숙소", "#뚜벅이여행"], +"reviewSignals": [{ "term": "조용한", "count": 41 }, { "term": "사진찍기 좋은", "count": 28 }] +``` + +빈도 높은 순으로 정렬해 레인 질의문을 만든다. 데이터가 없으면 레인 자체가 생기지 않는다. +현재 스테이머뭄에는 이 데이터가 없다 — 인스타그램은 로그인 월이라 스크래퍼가 채워야 한다. + #### 사실 기반 필터 — 벡터가 못 거르는 것 임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다. 그래서 코드 조건으로 배제한다. diff --git a/data/gunsan-pension-keywords.json b/data/gunsan-pension-keywords.json index e8c3d5c..5b4f956 100644 --- a/data/gunsan-pension-keywords.json +++ b/data/gunsan-pension-keywords.json @@ -480,6 +480,27 @@ "category": "의도", "relevance": 0.88 }, + { + "keyword": "군산 독채", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, + { + "keyword": "군산 한옥", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, + { + "keyword": "군산 숙박", + "intent": "local", + "kind": "keyword", + "category": "코어", + "relevance": 0.87 + }, { "keyword": "선유도 펜션", "intent": "local", @@ -1859,6 +1880,48 @@ "category": "동반자", "relevance": 0.73 }, + { + "keyword": "군산 2인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 2인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 2인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, + { + "keyword": "군산 3인 펜션", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.82 + }, + { + "keyword": "군산 3인 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.75 + }, + { + "keyword": "군산 3인 숙소", + "intent": "local", + "kind": "keyword", + "category": "동반자", + "relevance": 0.73 + }, { "keyword": "군산 4인 펜션", "intent": "local", @@ -3273,6 +3336,489 @@ "category": "시즌", "relevance": 0.69 }, + { + "keyword": "군산 감성 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 감성 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 조용한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 조용한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 분위기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 분위기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 예쁜 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 예쁜 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 사진찍기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 사진찍기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 인생샷 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 인생샷 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 뷰맛집 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 뷰맛집 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 뷰맛집 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 깔끔한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 깔끔한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 깔끔한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 신축 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 신축 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 신축 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 프라이빗한 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 프라이빗한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 프라이빗한 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 혼자 있기 좋은 펜션", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.81 + }, + { + "keyword": "군산 혼자 있기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.78 + }, + { + "keyword": "군산 혼자 있기 좋은 독채", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.7 + }, + { + "keyword": "군산 1박2일 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 1박2일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 2박3일 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 2박3일 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 당일치기 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 당일치기 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 주말여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 주말여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 뚜벅이 여행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 뚜벅이 여행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 혼행 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 혼행 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 워케이션 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 워케이션 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 무박 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 무박 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 한달살기 숙소", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.76 + }, + { + "keyword": "군산 한달살기 펜션 추천", + "intent": "local", + "kind": "keyword", + "category": "여행형태", + "relevance": 0.7 + }, + { + "keyword": "군산 커플 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 감성 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 조용한 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 분위기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 예쁜 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 사진찍기 좋은 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 커플 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 가족 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, + { + "keyword": "군산 친구 인생샷 숙소", + "intent": "local", + "kind": "keyword", + "category": "분위기", + "relevance": 0.58 + }, { "keyword": "선유도해수욕장 근처 펜션", "intent": "local", @@ -4302,6 +4848,146 @@ "category": "태그", "relevance": 0.6 }, + { + "keyword": "감성", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "분위기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "예쁜", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "사진찍기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "깔끔한", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "프라이빗한", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "혼자 있기 좋은", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "1박2일", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "2박3일", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "당일치기", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "주말여행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "뚜벅이 여행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "혼행", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "워케이션", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "무박", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "한달살기", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "2인전용", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "소인원", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "뚜벅이", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, + { + "keyword": "원도심", + "intent": "local", + "kind": "tag", + "category": "태그", + "relevance": 0.6 + }, { "keyword": "군산 커플 펜션 어디가 좋아요", "intent": "informational", @@ -6317,692 +7003,6 @@ "kind": "keyword", "category": "롱테일", "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 마당 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 벽난로 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 애견운동장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 키즈룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 트램폴린 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 무료주차 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 조식 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 프라이빗 세미나실 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 노을뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 일출뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 월풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 온수풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 야외수영장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 인피니티풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 불멍 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 화로대 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 넷플릭스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 빔프로젝터 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 노래방 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 파티룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 복층 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 테라스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 마당 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 벽난로 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 애견운동장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 키즈룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 트램폴린 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 무료주차 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 조식 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 스파 세미나실 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 노을뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 일출뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 월풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 온수풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 야외수영장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 인피니티풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 불멍 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 화로대 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 넷플릭스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 빔프로젝터 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 노래방 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 파티룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 복층 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 테라스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 마당 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 벽난로 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 애견운동장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 키즈룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 트램폴린 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 무료주차 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 조식 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 자쿠지 세미나실 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 노을뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 일출뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 월풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 온수풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 야외수영장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 인피니티풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 불멍 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 화로대 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 넷플릭스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 빔프로젝터 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 노래방 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 파티룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 복층 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 테라스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 마당 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 벽난로 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 애견운동장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 키즈룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 트램폴린 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 무료주차 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 조식 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 바베큐 세미나실 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 노을뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 일출뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 월풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 온수풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 야외수영장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 인피니티풀 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 불멍 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 화로대 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 넷플릭스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 빔프로젝터 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 노래방 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 파티룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 복층 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 테라스 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 마당 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 벽난로 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 애견운동장 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 키즈룸 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 트램폴린 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 무료주차 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 조식 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "군산 수영장 세미나실 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.4 - }, - { - "keyword": "새만금 오션뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.42 - }, - { - "keyword": "새만금 바다뷰 펜션", - "intent": "local", - "kind": "keyword", - "category": "롱테일", - "relevance": 0.42 } ] } diff --git a/data/related-keywords.sample.csv b/data/related-keywords.sample.csv new file mode 100644 index 0000000..dcf6a1c --- /dev/null +++ b/data/related-keywords.sample.csv @@ -0,0 +1,8 @@ +# 네이버 검색광고 > 도구 > 키워드도구 에서 내려받은 CSV 를 이 형태로 두면 된다. +# (아래 숫자는 형식 예시용 더미값 — 실제 데이터로 교체할 것) +relKeyword,monthlyPcQcCnt,monthlyMobileQcCnt,compIdx +군산독채펜션,210,1830,중간 +군산감성숙소,90,760,낮음 +군산2인펜션,40,310,낮음 +군산뚜벅이여행,30,240,낮음 +말랭이마을숙소,10,90,낮음 diff --git a/package.json b/package.json index 9322461..7d55b3a 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "db:reset": "docker compose down -v && docker compose up -d --wait && npm run db:migrate && npm run db:seed", "smoke": "tsx scripts/smoke.ts", "dataset:build": "node scripts/build-dataset.mjs", - "dataset:ingest": "tsx scripts/ingest-dataset.ts" + "dataset:ingest": "tsx scripts/ingest-dataset.ts", + "dataset:purge": "tsx scripts/purge-nondataset.ts", + "dataset:import-related": "tsx scripts/import-related.ts" }, "dependencies": { "@huggingface/transformers": "^4.2.0", diff --git a/scripts/build-dataset.mjs b/scripts/build-dataset.mjs index 4567c97..9595273 100644 --- a/scripts/build-dataset.mjs +++ b/scripts/build-dataset.mjs @@ -37,7 +37,15 @@ const INTENT_MORE = ['저렴한곳', '가성비', '최저가', '실시간예약' // 동반자 const WITH_CORE = ['커플', '가족', '친구', '애견동반', '단체']; const WITH_MORE = ['신혼', '아이동반', '유아동반', '부모님', '효도여행', '대학생', 'MT', '워크샵', - '회사', '태교여행', '혼자', '여자끼리', '4인', '6인', '10인', '20인']; + '회사', '태교여행', '혼자', '여자끼리', '2인', '3인', '4인', '6인', '10인', '20인']; + +// 분위기·취향 — 감성 독채 스테이 계열에서 실제로 많이 쓰이는 수식어 +const VIBE = ['감성', '조용한', '분위기 좋은', '예쁜', '사진찍기 좋은', '인생샷', '뷰맛집', + '깔끔한', '신축', '프라이빗한', '혼자 있기 좋은']; + +// 여행 형태 — 숙소 검색은 '며칠/어떻게 다니는가'로도 갈린다 +const TRAVEL = ['1박2일', '2박3일', '당일치기', '주말여행', '뚜벅이 여행', '혼행', + '워케이션', '무박', '한달살기']; // 시설·특징 const FEAT_CORE = ['오션뷰', '바다뷰', '독채', '프라이빗', '스파', '자쿠지', '바베큐', '수영장']; @@ -75,6 +83,9 @@ for (const s of STAY_CORE) for (const m of INTENT_CORE) add(`${REGION} ${s} ${m}`, { intent: intentOf(m), category: '코어', relevance: 0.93 }); for (const s of STAY_ALT) add(`${REGION} ${s}`, { category: '코어', relevance: 0.86 }); for (const m of INTENT_MORE) add(`${REGION} 펜션 ${m}`, { intent: intentOf(m), category: '의도', relevance: 0.88 }); +// 단독 명사형 — '군산 독채펜션' 만 있으면 '군산 독채' 검색을 놓친다 +for (const n of ['독채', '스테이', '풀빌라', '민박', '한옥', '글램핑', '숙박']) + add(`${REGION} ${n}`, { category: '코어', relevance: 0.87 }); // T2 — 섬·권역 × 숙소유형 for (const g of [ISLANDS, AREAS]) for (const p of g) for (const s of STAY_CORE.slice(0, 4)) @@ -118,6 +129,19 @@ for (const o of OCCASION) { add(`${REGION} ${o} 펜션 추천`, { category: '시즌', relevance: 0.69 }); } +// T5.5 — 분위기·여행형태 +for (const v of VIBE) { + add(`${REGION} ${v} 펜션`, { category: '분위기', relevance: 0.81 }); + add(`${REGION} ${v} 숙소`, { category: '분위기', relevance: 0.78 }); + add(`${REGION} ${v} 독채`, { category: '분위기', relevance: 0.7 }); +} +for (const t of TRAVEL) { + add(`${REGION} ${t} 숙소`, { category: '여행형태', relevance: 0.76 }); + add(`${REGION} ${t} 펜션 추천`, { category: '여행형태', relevance: 0.7 }); +} +for (const v of VIBE.slice(0, 6)) for (const w of WITH_CORE.slice(0, 3)) + add(`${REGION} ${w} ${v} 숙소`, { category: '분위기', relevance: 0.58 }); + // T6 — 관광지 앵커 for (const sp of SPOTS) { add(`${sp} 근처 펜션`, { category: '관광지', relevance: 0.83 }); @@ -129,7 +153,8 @@ for (const sp of SPOTS) { // T7 — 태그 (칩 UI 용 짧은 패싯) const TAGS = [...FEAT_CORE, ...FEAT_MORE, ...WITH_CORE, ...STAY_CORE, ...STAY_ALT, '오션뷰객실', '반려동물동반', '금연객실', '엘리베이터', '와이파이', '취사가능', - '단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷']; + '단체가능', '조용한', '신축', '리모델링', '뷰맛집', '인생샷', + ...VIBE, ...TRAVEL, '2인전용', '소인원', '뚜벅이', '원도심']; for (const t of TAGS) add(t, { kind: 'tag', category: '태그', relevance: 0.6 }); // T8 — 질문형 (AEO) diff --git a/scripts/import-related.ts b/scripts/import-related.ts new file mode 100644 index 0000000..fe03af5 --- /dev/null +++ b/scripts/import-related.ts @@ -0,0 +1,90 @@ +/** + * 외부 연관키워드·검색량을 데이터셋에 병합한다. + * npx tsx scripts/import-related.ts data/related-keywords.csv [--apply] + * + * 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON. + * relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx + * + * API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이 + * 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고, + * 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; + +const DATASET = 'data/gunsan-pension-keywords.json'; + +interface Related { keyword: string; volumePc: number; volumeMobile: number; competition: string | null } + +function parse(path: string): Related[] { + const raw = readFileSync(path, 'utf8'); + if (path.endsWith('.json')) { + return (JSON.parse(raw) as any[]).map(toRelated); + } + const lines = raw.split(/\r?\n/).filter((l) => l.trim() && !l.trimStart().startsWith('#')); + const head = lines.shift()!.split(',').map((h) => h.trim()); + return lines.map((line) => { + const cells = line.split(',').map((c) => c.trim()); + const o: Record = {}; + head.forEach((h, i) => (o[h] = cells[i] ?? '')); + return toRelated(o); + }); +} + +function toRelated(o: any): Related { + const num = (v: unknown) => { + const n = Number(String(v ?? '').replace(/[^0-9]/g, '')); + return Number.isFinite(n) ? n : 0; + }; + return { + keyword: String(o.relKeyword ?? o.keyword ?? '').trim(), + volumePc: num(o.monthlyPcQcCnt), + volumeMobile: num(o.monthlyMobileQcCnt), + competition: o.compIdx ? String(o.compIdx).trim() : null, + }; +} + +function main() { + const file = process.argv[2]; + const apply = process.argv.includes('--apply'); + if (!file) { console.error('사용법: tsx scripts/import-related.ts [--apply]'); process.exit(1); } + + const ds = JSON.parse(readFileSync(DATASET, 'utf8')); + const existing = new Map(ds.items.map((i: any) => [normalizeKeyword(i.keyword), i])); + + const rows = parse(file).filter((r) => r.keyword); + let added = 0, enriched = 0, skipped = 0; + const newItems: any[] = []; + + for (const r of rows) { + const canonical = canonicalizeKeyword(r.keyword); + const norm = normalizeKeyword(canonical); + if (!norm || isBanned(canonical)) { skipped++; continue; } + const volume = r.volumePc + r.volumeMobile; + const hit = existing.get(norm); + if (hit) { + hit.volume = volume; hit.competition = r.competition; hit.volumeSource = 'naver-searchad'; + enriched++; + } else { + const item = { + keyword: canonical, intent: 'local', kind: 'keyword', category: '연관', + relevance: 0.7, volume, competition: r.competition, volumeSource: 'naver-searchad', + }; + newItems.push(item); existing.set(norm, item); added++; + } + } + + console.log(`입력 ${rows.length}건 → 신규 ${added} · 기존 보강 ${enriched} · 제외 ${skipped}`); + if (newItems.length) { + console.log('\n신규 예시'); + for (const i of newItems.slice(0, 8)) console.log(` ${i.keyword} (월 ${i.volume}, 경쟁 ${i.competition ?? '-'})`); + } + if (!apply) { console.log('\n파일에 쓰려면 --apply 를 붙일 것.'); return; } + + ds.items = [...ds.items, ...newItems]; + ds.count = ds.items.length; + writeFileSync(DATASET, JSON.stringify(ds, null, 2) + '\n'); + console.log(`\n✅ ${DATASET} → ${ds.count}건`); +} + +main(); diff --git a/scripts/ingest-dataset.ts b/scripts/ingest-dataset.ts index eaefb16..0e50243 100644 --- a/scripts/ingest-dataset.ts +++ b/scripts/ingest-dataset.ts @@ -72,7 +72,22 @@ async function main() { }); console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); - // 4) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만 + // 4) 데이터셋에서 빠진 행 정리. + // upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다. + // (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다) + const wanted = uniq.map(([norm]) => norm); + const stale = await sql>` + DELETE FROM keyword + WHERE source = 'dataset' AND locale = 'ko-KR' AND NOT (normalized = ANY(${wanted})) + RETURNING canonical`; + console.log(` 🧹 이전 판본 잔여 ${stale.length}건 삭제` + + (stale.length ? ` (예: ${stale.slice(0, 4).map((r) => r.canonical).join(', ')})` : '')); + + const [{ n: total }] = await sql>` + SELECT count(*)::int AS n FROM keyword WHERE source = 'dataset'`; + console.log(` 📚 사전 현재 ${total}건`); + + // 5) 벡터 근접쌍 — 자동 병합하지 않고 검토 목록으로만 const near = await sql>` SELECT k1.canonical AS a, k2.canonical AS b, 1 - (k1.embedding <=> k2.embedding) AS sim FROM keyword k1 JOIN keyword k2 diff --git a/scripts/purge-nondataset.ts b/scripts/purge-nondataset.ts new file mode 100644 index 0000000..7282318 --- /dev/null +++ b/scripts/purge-nondataset.ts @@ -0,0 +1,35 @@ +/** + * 고정 데이터셋 정책 위반분 정리. + * npx tsx scripts/purge-nondataset.ts [--apply] + * + * 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가 + * 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다. + */ +import { createSql } from '../src/db/db'; + +async function main() { + const apply = process.argv.includes('--apply'); + const sql = createSql(); + + const rows = await sql>` + SELECT source, count(*)::int AS n, (array_agg(canonical ORDER BY canonical))[1:6] AS sample + FROM keyword GROUP BY source ORDER BY n DESC`; + console.log('출처별 현황'); + for (const r of rows) console.log(` ${r.source.padEnd(10)} ${String(r.n).padStart(5)} ${r.sample.join(', ')}`); + + const doomed = await sql>` + SELECT count(*)::int AS n FROM keyword WHERE source NOT IN ('dataset', 'manual')`; + const n = doomed[0]?.n ?? 0; + if (n === 0) { console.log('\n정리 대상 없음'); await sql.end(); return; } + + if (!apply) { + console.log(`\n정리 대상 ${n}건. 실제로 지우려면 --apply 를 붙일 것.`); + await sql.end(); + return; + } + const del = await sql`DELETE FROM keyword WHERE source NOT IN ('dataset', 'manual') RETURNING id`; + console.log(`\n✅ ${del.length}건 삭제 (merchant_keyword 는 CASCADE)`); + await sql.end(); +} + +main().catch((e) => { console.error('❌', e); process.exit(1); }); diff --git a/src/serving/match.rules.ts b/src/serving/match.rules.ts index c06550f..2a43e47 100644 --- a/src/serving/match.rules.ts +++ b/src/serving/match.rules.ts @@ -21,6 +21,8 @@ export interface MerchantFacts { nearby: string[]; amenities: Set; // 정규화된 보유 시설 unverified: Set; // 미확인 — 배제하지 않고 보류 처리 + /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */ + signals: string[]; } export type AreaGroup = '해안·도서' | '원도심' | '시내'; @@ -133,8 +135,22 @@ const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모| */ export function buildLanes(f: MerchantFacts): Lane[] { const lanes: Lane[] = []; + // 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과 + // '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고, + // 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다). const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => { - const text = [...new Set(parts.filter(Boolean) as string[])].join(' ').replace(/\s+/g, ' ').trim(); + const seen = new Set(); + const words: string[] = []; + for (const part of parts) { + if (!part) continue; + for (const w of String(part).split(/\s+/)) { + const k = w.trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + words.push(k); + } + } + const text = words.join(' '); if (text) lanes.push({ key, label, weight, text }); }; @@ -153,6 +169,8 @@ export function buildLanes(f: MerchantFacts): Lane[] { push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']); push('audience', '동반자', 0.6, f.audiences.slice(0, 4)); push('amenity', '시설', 0.6, amenityWords); + // 고객 언어는 사업자 표현보다 검색어에 가깝다 — 데이터가 있으면 높게 잡는다 + push('signal', '고객언어', 0.9, f.signals.slice(0, 10)); return lanes; } diff --git a/src/serving/match.service.ts b/src/serving/match.service.ts index 237cdfb..434029a 100644 --- a/src/serving/match.service.ts +++ b/src/serving/match.service.ts @@ -14,6 +14,8 @@ import { const RRF_K = 20; const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다 const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 +// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. +const MATCH_SOURCES = ['dataset', 'manual']; interface Hit { id: string; canonical: string; intent: string; kind: string; @@ -47,9 +49,11 @@ export class MatchService { 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)), + vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)), ); // 가중 RRF 융합 @@ -137,13 +141,17 @@ export class MatchService { }; } - private async laneSearch(embedding: number[], limit: number): Promise { + private async laneSearch( + embedding: number[], limit: number, industryId: 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 LIMIT ${limit}`; return rows.map((r) => ({ ...r, score: Number(r.score) })); @@ -180,7 +188,8 @@ export class MatchService { async dictionarySize() { const [row] = await this.sql>` - SELECT count(*)::int AS n FROM keyword WHERE embedding IS NOT NULL`; + SELECT count(*)::int AS n FROM keyword + WHERE embedding IS NOT NULL AND source = ANY(${MATCH_SOURCES})`; return row?.n ?? 0; } } @@ -213,9 +222,28 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts { nearby, amenities: normalizeAmenities([...features, ...services]), unverified: new Set(str(p['unverified'])), + signals: collectSignals(p), }; } +/** + * 고객 언어 신호를 모은다. + * hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등 + * reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계 + * 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다. + */ +function collectSignals(p: Record): 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,