From 6596254a4a745e0b0d9d23d184e285fdf843677e Mon Sep 17 00:00:00 2001 From: takch02 Date: Sat, 16 May 2026 21:39:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EB=B3=91=ED=95=A9=20=EC=A0=84=EB=9E=B5=EC=9D=84=20?= =?UTF-8?q?=EA=B0=80=EC=A4=91=ED=95=A9+=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=EB=B6=80=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20RRF=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 벡터 점수(0~1)와 BM25 텍스트 점수(unbounded)의 스케일 불일치로 가중합이 정규화 실패 → 키워드부스트(+0.3 flat)가 관련성보다 키워드 일치 여부에 지배되는 문제 수정. RRF(K=60)는 절대 점수 대신 순위 기반 융합으로 스케일 문제를 근본적으로 해결. 한쪽 결과에만 존재하는 문서는 GHOST_RANK=500으로 처리해 결과 집합 크기와 무관한 고정 페널티 부여. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/partners/partners.service.ts | 84 +++++++++++------------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 41e791e..9322912 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -386,57 +386,47 @@ export class PartnersService { await Promise.all([textSearchTask, vectorSearchTask]); } - // 1.3 Merge Results + // 1.3 Merge Results (Reciprocal Rank Fusion) let dbResults: any[] = []; if (vectorResults.length > 0 || textResults.length > 0) { - const resultMap = new Map(); - const boostKeywords = (aiKeywords || q || "") - .split(/[\s,]+/) - .map((w) => w.replace(/[.,]/g, "").trim()) - .filter((w) => w.length > 1); - - // Add vector results first - vectorResults.forEach((r) => { - resultMap.set(r._id.toString(), { - ...r, - vectorScore: r.score, - textScore: 0, - score: r.score * 0.7, - }); - }); - - textResults.forEach((r) => { - const id = r._id.toString(); - const normText = Math.min(1.0, r.textScore / 12); - if (resultMap.has(id)) { - const existing = resultMap.get(id); - existing.textScore = r.textScore; - existing.score = existing.vectorScore * 0.4 + normText * 0.4 + 0.2; - } else { - resultMap.set(id, { - ...r, - vectorScore: 0, - textScore: r.textScore, - score: normText * 0.7, - }); - } - }); + const K = 60; - // Post-Processing: Boost items whose names or industry contain AI keywords - resultMap.forEach((item) => { - const name = (item.name || "").toLowerCase(); - const industryText = (item.industry || "").toLowerCase(); - const hasKeywordMatch = boostKeywords.some((kw) => { - const kL = kw.toLowerCase(); - const match = name.includes(kL) || industryText.includes(kL); - return match; - }); + const vectorRankMap = new Map( + [...vectorResults] + .sort((a, b) => b.score - a.score) + .map((r, i) => [r._id.toString(), i + 1]), + ); + const textRankMap = new Map( + [...textResults] + .sort((a, b) => b.textScore - a.textScore) + .map((r, i) => [r._id.toString(), i + 1]), + ); - if (hasKeywordMatch) { - item.score = Math.min(1.0, item.score + 0.3); - } - }); - dbResults = Array.from(resultMap.values()) + const allIds = new Set([ + ...vectorResults.map((r) => r._id.toString()), + ...textResults.map((r) => r._id.toString()), + ]); + + const vectorDocMap = new Map(); + vectorResults.forEach((r) => vectorDocMap.set(r._id.toString(), r)); + const textDocMap = new Map(); + textResults.forEach((r) => textDocMap.set(r._id.toString(), r)); + + dbResults = [...allIds] + .map((id) => { + const GHOST_RANK = 500; + const vRank = vectorRankMap.get(id) ?? GHOST_RANK; + const tRank = textRankMap.get(id) ?? GHOST_RANK; + const base = vectorDocMap.get(id) ?? textDocMap.get(id); + return { + ...base, + vectorScore: vectorDocMap.get(id)?.score ?? 0, + textScore: textDocMap.get(id)?.textScore ?? 0, + score: 1 / (K + vRank) + 1 / (K + tRank), + vectorRank: vRank, + textRank: tRank, + }; + }) .sort((a, b) => b.score - a.score) .slice(0, Number(limit)); } From 9abb8932b3a4e5e9d4e2d6d6a191cf172a5402d1 Mon Sep 17 00:00:00 2001 From: takch02 Date: Sat, 16 May 2026 21:41:04 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B2=94=EC=9A=A9=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B8=94=EB=A1=9D=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B5=ED=95=A9=EC=96=B4=20=EB=B3=B4=EC=A1=B4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM이 생성한 "솔루션", "Solution", "Technology" 같은 범용 키워드가 MongoDB $text 검색에 사용되면 관련 없는 기업이 텍스트 점수를 얻는 문제 수정. GENERIC_KEYWORD_BLOCKLIST로 한/영 범용어를 필터링하고, 구분자를 쉼표 전용(\s*,\s*)으로 변경해 "Smart Farm" 같은 복합어가 분리되지 않도록 보존. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/partners/partners.service.ts | 60 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 9322912..2b16306 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -217,7 +217,7 @@ export class PartnersService { // 1.0 AI Query Understanding (HyDE + Keywords) const aiAnalysis = await this.generateHyDEAndKeywords(q, detectedIntent); hydeDocument = aiAnalysis.profile; - aiKeywords = aiAnalysis.keywords; + aiKeywords = filterGenericKeywords(aiAnalysis.keywords); this.logger.log( `[Step 2.AI Analysis] took ${Math.round(performance.now() - aiStartTime)}ms. Keywords: "${aiKeywords}"`, @@ -883,6 +883,64 @@ Output in JSON: { "profile": "...", "keywords": "..." }`, } } +// --- Generic keyword filter --- +const GENERIC_KEYWORD_BLOCKLIST = new Set([ + "솔루션", + "기술", + "서비스", + "시스템", + "플랫폼", + "제품", + "제품군", + "글로벌", + "혁신", + "스마트", + "디지털", + "자동화", + "인공지능", + "분야", + "전문", + "개발", + "공급", + "사업", + "기업", + "회사", + "산업", + "solution", + "solutions", + "technology", + "technologies", + "service", + "services", + "system", + "systems", + "platform", + "platforms", + "product", + "products", + "global", + "innovation", + "smart", + "digital", + "automation", + "ai", + "company", + "business", + "industry", + "development", + "supply", +]); + +function filterGenericKeywords(keywords: string): string { + const filtered = keywords + .split(/\s*,\s*/) // 쉼표 기준으로만 분리 — 복합어("Smart Farm") 보존 + .map((w) => w.trim()) + .filter( + (w) => w.length > 0 && !GENERIC_KEYWORD_BLOCKLIST.has(w.toLowerCase()), + ); + return filtered.join(" ") || keywords; // 전부 걸리면 원본 fallback +} + // --- Keyword constants --- const AUTOMOTIVE_KEYWORDS = [ "자동차", From 22417aaec2ef4c6c801a65e7f26d797cfe00f2aa Mon Sep 17 00:00:00 2001 From: takch02 Date: Sat, 16 May 2026 21:41:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20debug=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20aiKeywords=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterGenericKeywords 적용 후 실제 $text 검색에 사용된 키워드를 debug 응답에서 확인할 수 있도록 aiKeywords 필드 노출. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/partners/partners.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 2b16306..edfdb1f 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -716,6 +716,7 @@ export class PartnersService { forceWebSearch, tavilyQuery: tavilyQuery || null, hydeDocument, + aiKeywords, duration: `${totalDuration}ms`, }, }; From 79aad0603eb3262689bdba875319d02b8c18448a Mon Sep 17 00:00:00 2001 From: takch02 Date: Sun, 17 May 2026 14:54:14 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EC=83=81=EC=88=98=20=EB=B0=96?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B9=BC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/partners/partners.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index edfdb1f..3850eec 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -390,6 +390,7 @@ export class PartnersService { let dbResults: any[] = []; if (vectorResults.length > 0 || textResults.length > 0) { const K = 60; + const GHOST_RANK = 500; const vectorRankMap = new Map( [...vectorResults] @@ -414,7 +415,6 @@ export class PartnersService { dbResults = [...allIds] .map((id) => { - const GHOST_RANK = 500; const vRank = vectorRankMap.get(id) ?? GHOST_RANK; const tRank = textRankMap.get(id) ?? GHOST_RANK; const base = vectorDocMap.get(id) ?? textDocMap.get(id); From ba19b17a852172bfcb7353b95b7e3ed8b432bd33 Mon Sep 17 00:00:00 2001 From: takch02 Date: Sun, 17 May 2026 14:58:34 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20RRF=20=EC=A0=84=EB=9E=B5=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=A7=9E=EA=B2=8C=20partners=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=9C=A0=EB=8B=9B=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가중합+키워드부스트 기반 score 기대값을 RRF 공식 1/(K+ghostRank) + 1/(K+textRank)으로 업데이트. Co-Authored-By: Claude Sonnet 4.6 --- src/modules/partners/partners.service.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/modules/partners/partners.service.spec.ts b/src/modules/partners/partners.service.spec.ts index bbd230c..bf3d59d 100644 --- a/src/modules/partners/partners.service.spec.ts +++ b/src/modules/partners/partners.service.spec.ts @@ -146,7 +146,7 @@ describe("PartnersService", () => { expect(result.provider).toBe("db"); }); - it("embed 빈 벡터 → 텍스트 검색 폴백, 스코어 정규화 및 부스트", async () => { + it("embed 빈 벡터 → 텍스트 검색 폴백, RRF 점수 적용", async () => { embeddingsService.embed.mockResolvedValue([]); sellerModel.find.mockReturnValue( buildQueryMock([ @@ -162,8 +162,10 @@ describe("PartnersService", () => { const result = await service.search({ q: "화장품" }); expect(sellerModel.find).toHaveBeenCalled(); - // 계산: (min(1, 12/12) * 0.7) + 0.3(부스트) = 0.7 + 0.3 = 1.0 - expect(result.data[0].score).toBeCloseTo(1.0); + // RRF: 텍스트 rank=1, 벡터 없음(GHOST_RANK=500) + // score = 1/(60+500) + 1/(60+1) ≈ 0.0182 + const expected = 1 / (60 + 500) + 1 / (60 + 1); + expect(result.data[0].score).toBeCloseTo(expected, 4); }); it("벡터 검색 결과 0건 → 텍스트 검색 폴백", async () => { From 7470ccae209e12be329d5b83b3d59eae7b3dc251 Mon Sep 17 00:00:00 2001 From: takch02 Date: Sun, 17 May 2026 15:02:00 +0900 Subject: [PATCH 6/6] =?UTF-8?q?aiKeywords=20=EC=97=86=EC=9D=84=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=98=88=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/partners/partners.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 3850eec..80b93f7 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -933,6 +933,7 @@ const GENERIC_KEYWORD_BLOCKLIST = new Set([ ]); function filterGenericKeywords(keywords: string): string { + if (!keywords) return keywords ?? ""; const filtered = keywords .split(/\s*,\s*/) // 쉼표 기준으로만 분리 — 복합어("Smart Farm") 보존 .map((w) => w.trim())