Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/modules/partners/partners.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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 () => {
Expand Down
146 changes: 98 additions & 48 deletions src/modules/partners/partners.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

this.logger.log(
`[Step 2.AI Analysis] took ${Math.round(performance.now() - aiStartTime)}ms. Keywords: "${aiKeywords}"`,
Expand Down Expand Up @@ -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<string, any>();
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<string, any>();
vectorResults.forEach((r) => vectorDocMap.set(r._id.toString(), r));
const textDocMap = new Map<string, any>();
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));
}
Expand Down Expand Up @@ -726,6 +716,7 @@ export class PartnersService {
forceWebSearch,
tavilyQuery: tavilyQuery || null,
hydeDocument,
aiKeywords,
duration: `${totalDuration}ms`,
},
};
Expand Down Expand Up @@ -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 = [
"자동차",
Expand Down
Loading