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 () => { diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 41e791e..80b93f7 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}"`, @@ -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; + const GHOST_RANK = 500; - // 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 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)); } @@ -726,6 +716,7 @@ export class PartnersService { forceWebSearch, tavilyQuery: tavilyQuery || null, hydeDocument, + aiKeywords, duration: `${totalDuration}ms`, }, }; @@ -893,6 +884,65 @@ 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 { + if (!keywords) return keywords ?? ""; + 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 = [ "자동차",