diff --git a/README.md b/README.md index 0127926..4ddb15d 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,6 @@ docker-compose up -d | Buyer | `rJvbMhFjmfAd5DZAVhXe7kuPBKmhBMkaCH` | | Seller | `ra7MVxG3MUCqym6opZBQXj9bSx5P7s5B4Y` | -> Testnet Faucet: https://xrpl.org/xrp-testnet-faucet.html --- diff --git a/src/modules/partners/partners.controller.spec.ts b/src/modules/partners/partners.controller.spec.ts index b8c6704..6b5097d 100644 --- a/src/modules/partners/partners.controller.spec.ts +++ b/src/modules/partners/partners.controller.spec.ts @@ -38,24 +38,11 @@ describe("PartnersController", () => { it("모든 파라미터를 서비스에 전달", async () => { mockPartnersService.search.mockResolvedValue({ data: [] }); - await controller.search( - null, - "acme", - 5, - "Automotive", - "Korea", - "OEM", - "1-10", - "buyer-id", - ); + await controller.search(null, "acme", 5, "buyer-id"); expect(mockPartnersService.search).toHaveBeenCalledWith({ q: "acme", limit: 5, - industry: "Automotive", - country: "Korea", - partnership: "OEM", - size: "1-10", buyerId: "buyer-id", }); }); diff --git a/src/modules/partners/partners.controller.ts b/src/modules/partners/partners.controller.ts index ebe9898..29f4708 100644 --- a/src/modules/partners/partners.controller.ts +++ b/src/modules/partners/partners.controller.ts @@ -32,18 +32,6 @@ export class PartnersController { type: Number, description: "결과 수 (기본 10)", }) - @ApiQuery({ name: "industry", required: false, description: "산업 필터" }) - @ApiQuery({ name: "country", required: false, description: "국가 필터" }) - @ApiQuery({ - name: "partnership", - required: false, - description: "파트너십 태그 필터", - }) - @ApiQuery({ - name: "size", - required: false, - description: "기업 규모 (예: 1-10, 11-50, 51-200)", - }) @ApiQuery({ name: "buyerId", required: false, @@ -60,19 +48,11 @@ export class PartnersController { @OptionalCurrentUser() user: SessionUser | null, @Query("q") q: string, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit?: number, - @Query("industry") industry?: string, - @Query("country") country?: string, - @Query("partnership") partnership?: string, - @Query("size") size?: string, @Query("buyerId") buyerId?: string, ) { return this.partnersService.search({ q, limit, - industry, - country, - partnership, - size, buyerId, userId: user?.userId, }); diff --git a/src/modules/partners/partners.service.spec.ts b/src/modules/partners/partners.service.spec.ts index bf3d59d..d812247 100644 --- a/src/modules/partners/partners.service.spec.ts +++ b/src/modules/partners/partners.service.spec.ts @@ -97,37 +97,17 @@ describe("PartnersService", () => { // ── browse 모드 ─────────────────────────────────────────────────────────────── - describe("browse 모드 (필터만, 쿼리 없음)", () => { - it("industry 매핑 필터 적용 후 score=1.0", async () => { - // AI 분석 모킹 - jest.spyOn(service as any, "generateHyDEAndKeywords").mockResolvedValue({ - profile: "", - keywords: "", - }); - + describe("browse 모드 (쿼리 없음, 전체 조회)", () => { + it("쿼리 없을 때 전체 조회 후 score=1.0", async () => { sellerModel.find.mockReturnValue( buildQueryMock([{ _id: "c1", name: "Acme" }]), ); - const result = await service.search({ - q: "", - industry: "IT / AI / SaaS", - }); + const result = await service.search({ q: "" }); expect(sellerModel.find).toHaveBeenCalled(); expect(result.data[0].score).toBe(1.0); }); - - it("country 필터 적용", async () => { - sellerModel.find.mockReturnValue(buildQueryMock([])); - - await service.search({ q: "", country: "Korea" }); - - expect(sellerModel.find).toHaveBeenCalledWith( - expect.objectContaining({ "location.country": "Korea" }), - expect.any(Object), - ); - }); }); // ── 벡터 검색 ───────────────────────────────────────────────────────────────── @@ -137,6 +117,8 @@ describe("PartnersService", () => { embeddingsService.embed.mockResolvedValue(new Array(64).fill(0.1)); sellerModel.aggregate.mockResolvedValue([ { _id: "c1", name: "Acme", score: 0.9 }, + { _id: "c2", name: "Beta", score: 0.8 }, + { _id: "c3", name: "Gamma", score: 0.7 }, ]); const result = await service.search({ q: "화장품 제조사" }); @@ -156,6 +138,8 @@ describe("PartnersService", () => { industry: "Beauty / Consumer Goods / Food", score: 12, }, + { _id: "c2", name: "Alpha Corp", score: 8 }, + { _id: "c3", name: "Beta Corp", score: 4 }, ]), ); @@ -172,12 +156,16 @@ describe("PartnersService", () => { embeddingsService.embed.mockResolvedValue(new Array(64).fill(0.1)); sellerModel.aggregate.mockResolvedValue([]); // 벡터 결과 없음 sellerModel.find.mockReturnValue( - buildQueryMock([{ _id: "c2", name: "Beta", score: 3 }]), + buildQueryMock([ + { _id: "c2", name: "Beta", score: 3 }, + { _id: "c3", name: "Alpha", score: 2 }, + { _id: "c4", name: "Gamma", score: 1 }, + ]), ); const result = await service.search({ q: "식품" }); - expect(result.data).toHaveLength(1); + expect(result.data).toHaveLength(3); expect(result.data[0].name).toBe("Beta"); }); }); @@ -189,6 +177,8 @@ describe("PartnersService", () => { buyerModel.find.mockReturnValue( buildQueryMock([ { _id: "b1", name_kr: "라온바이어", country: "US", score: 10 }, + { _id: "b2", name_kr: "서울바이어", country: "KR", score: 8 }, + { _id: "b3", name_kr: "부산바이어", country: "KR", score: 6 }, ]), ); @@ -286,7 +276,6 @@ describe("PartnersService", () => { await service.search({ q: "", - industry: "IT / AI / SaaS", userId: USER_ID, }); diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 80b93f7..626d8e6 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -7,52 +7,9 @@ import { Buyer, BuyerDocument } from "../buyers/schemas/buyer.schema"; import { User, UserDocument } from "../users/schemas/user.schema"; import { EmbeddingsService } from "../embeddings/embeddings.service"; -const INDUSTRY_MAPPING: Record = { - "Automotive / EV Parts": [ - "Mobility / Automation / Manufacturing", - "Industrial & Manufacturing", - "Mobility", - "Automotive", - "Car parts", - "EV", - ], - "IT / AI / SaaS": ["IT / AI / SaaS", "Tech & Electronics", "Software"], - "Healthcare / Bio / Medical": [ - "Healthcare / Bio / Medical", - "Health & Bio", - "Medical", - ], - "Green Energy / Climate Tech / Smart City": [ - "Green Energy / Climate Tech / Smart City", - "Energy & Environment", - ], - "Mobility / Automation / Manufacturing": [ - "Mobility / Automation / Manufacturing", - "Industrial & Manufacturing", - "Mobility", - ], - "Beauty / Consumer Goods / Food": [ - "Beauty / Consumer Goods / Food", - "Beauty & Cosmetics", - "Food & Beverage", - "Consumer Goods", - ], - "Content / Culture / Edutech": [ - "Content / Culture / Edutech", - "Content", - "Education", - ], - "Fintech / Smart Finance": ["Fintech / Smart Finance", "Finance"], - Other: ["Other", "(Unspecified)"], -}; - export interface SearchOptions { q: string; limit?: number; - industry?: string; - country?: string; - partnership?: string; - size?: string; buyerId?: string; userId?: string; } @@ -95,8 +52,6 @@ const mapBuyerToCommon = (b: any) => ({ updatedAt: b.updatedAt, }); -const escapeRegex = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - @Injectable() export class PartnersService { private readonly logger = new Logger(PartnersService.name); @@ -113,25 +68,16 @@ export class PartnersService { async search(opts: SearchOptions): Promise { const startTime = performance.now(); - const { - q, - limit = 10, - industry, - country, - partnership, - size, - buyerId, - } = opts; + const { q, limit = 10, buyerId } = opts; let forceWebSearch = false; let tavilyQuery = q ?? ""; let detectedIntent = "seller"; let intentData: any = null; let aiResponse = ""; + let detectedCountry: string | null = null; - this.logger.log( - `[Search Start] query: "${q}", industry: ${industry}, country: ${country}`, - ); + this.logger.log(`[Search Start] query: "${q}"`); if (q) { const intentStartTime = performance.now(); @@ -184,8 +130,10 @@ export class PartnersService { forceWebSearch = true; } + detectedCountry = detectCountryFromQuery(qLower); + this.logger.log( - `[Step 1: Intent Analysis] took ${Math.round(performance.now() - intentStartTime)}ms. detectedIntent: ${detectedIntent}, forceWebSearch: ${forceWebSearch}`, + `[Step 1: Intent Analysis] took ${Math.round(performance.now() - intentStartTime)}ms. detectedIntent: ${detectedIntent}, forceWebSearch: ${forceWebSearch}, detectedCountry: ${detectedCountry}`, ); } @@ -211,7 +159,7 @@ export class PartnersService { let hydeDocument: string | null = null; let aiKeywords: string | null = null; - if (!forceWebSearch && q) { + if (q) { const aiStartTime = performance.now(); // 1.0 AI Query Understanding (HyDE + Keywords) @@ -227,36 +175,10 @@ export class PartnersService { const textSearchTask = (async () => { const textStartTime = performance.now(); const filter: Record = { $text: { $search: aiKeywords } }; - if (industry) { - if (isBuyerSearch) { - filter.$and = [ - { $text: { $search: aiKeywords } }, - { - $or: [ - { - industry_kr: { - $regex: escapeRegex(industry), - $options: "i", - }, - }, - { - industry_en: { - $regex: escapeRegex(industry), - $options: "i", - }, - }, - ], - }, - ]; - } else { - filter.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) filter.country = country; - else filter["location.country"] = country; + if (detectedCountry) { + const countryRegex = new RegExp(detectedCountry, "i"); + if (isBuyerSearch) filter.country = countryRegex; + else filter["location.country"] = countryRegex; } if (excludeObjectIds.length > 0) filter._id = { $nin: excludeObjectIds }; @@ -319,28 +241,17 @@ export class PartnersService { index: indexName, path: "embedding", queryVector: vector, - numCandidates: 100, - limit: 100, + numCandidates: 500, // 데이터 규모에 따라 조정해야함 + limit: 200, // 데이터 규모에 따라 조정해야함 }, }, ]; const matchStage: Record = {}; - if (industry) { - if (isBuyerSearch) { - matchStage.$or = [ - { industry_kr: { $regex: industry, $options: "i" } }, - { industry_en: { $regex: industry, $options: "i" } }, - ]; - } else { - matchStage.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) matchStage.country = country; - else matchStage["location.country"] = country; + if (detectedCountry) { + const countryRegex = new RegExp(detectedCountry, "i"); + if (isBuyerSearch) matchStage.country = countryRegex; + else matchStage["location.country"] = countryRegex; } if (excludeObjectIds.length > 0) matchStage._id = { $nin: excludeObjectIds }; @@ -372,6 +283,9 @@ export class PartnersService { }); } + this.logger.log( + `[Step 2.Vector] queryVector dim=${vector.length}, index=${indexName}`, + ); try { const raw = await activeModel.aggregate(pipeline); vectorResults = isBuyerSearch ? raw.map(mapBuyerToCommon) : raw; @@ -379,7 +293,9 @@ export class PartnersService { `[Step 2.Vector] Found ${vectorResults.length} items`, ); } catch (err: any) { - this.logger.error(`[Search] Vector search error: ${err.message}`); + this.logger.error( + `[Search] Vector search error: ${JSON.stringify(err)}`, + ); } })(); @@ -413,6 +329,7 @@ export class PartnersService { const textDocMap = new Map(); textResults.forEach((r) => textDocMap.set(r._id.toString(), r)); + const seenNames = new Set(); dbResults = [...allIds] .map((id) => { const vRank = vectorRankMap.get(id) ?? GHOST_RANK; @@ -428,64 +345,21 @@ export class PartnersService { }; }) .sort((a, b) => b.score - a.score) + .filter((r) => { + const key = ( + r.name || + r.name_kr || + r.name_en || + r.nameEn || + "" + ).trim(); + if (!key || seenNames.has(key)) return false; + seenNames.add(key); + return true; + }) .slice(0, Number(limit)); } - // --- 1.8. Filter-only browsing --- - if ( - !forceWebSearch && - !q && - (industry || country || (isBuyerSearch ? false : partnership || size)) - ) { - const filter: Record = {}; - if (industry) { - if (isBuyerSearch) { - filter.$or = [ - { industry_kr: { $regex: industry, $options: "i" } }, - { industry_en: { $regex: industry, $options: "i" } }, - ]; - } else { - filter.industry = INDUSTRY_MAPPING[industry] - ? { $in: INDUSTRY_MAPPING[industry] } - : industry; - } - } - if (country) { - if (isBuyerSearch) filter.country = country; - else filter["location.country"] = country; - } - if (!isBuyerSearch) { - if (partnership) filter.tags = partnership; - if (size) filter.sizeBucket = size; - } - if (excludeObjectIds.length > 0) filter._id = { $nin: excludeObjectIds }; - - const projection = isBuyerSearch - ? { - name_kr: 1, - name_en: 1, - industry_kr: 1, - industry_en: 1, - country: 1, - intro_kr: 1, - intro_en: 1, - website: 1, - email: 1, - updatedAt: 1, - } - : SEARCH_PROJECTION; - - const raw = await activeModel - .find(filter as any, projection as any) - .limit(Number(limit)) - .sort({ updatedAt: -1 } as any) - .lean(); - dbResults = raw.map((r) => { - const common = isBuyerSearch ? mapBuyerToCommon(r) : r; - return { ...common, score: 1.0 }; - }); - } - // --- 1.9. Default show-all --- if (!forceWebSearch && !q && dbResults.length === 0) { const projection = isBuyerSearch @@ -519,7 +393,7 @@ export class PartnersService { } // --- 2. Web search fallback --- - const shouldFallbackToWeb = forceWebSearch || dbResults.length === 0; + const shouldFallbackToWeb = dbResults.length < 3; if (shouldFallbackToWeb && q) { const webStartTime = performance.now(); @@ -1069,6 +943,93 @@ const REGION_KEYWORDS = [ "north america", ]; +const COUNTRY_QUERY_MAP: { kr: string; en: string }[] = [ + { kr: "미국", en: "USA" }, + { kr: "캐나다", en: "Canada" }, + { kr: "멕시코", en: "Mexico" }, + { kr: "브라질", en: "Brazil" }, + { kr: "칠레", en: "Chile" }, + { kr: "아르헨티나", en: "Argentina" }, + { kr: "콜롬비아", en: "Colombia" }, + { kr: "페루", en: "Peru" }, + { kr: "영국", en: "UK" }, + { kr: "독일", en: "Germany" }, + { kr: "프랑스", en: "France" }, + { kr: "이탈리아", en: "Italy" }, + { kr: "스페인", en: "Spain" }, + { kr: "네덜란드", en: "Netherlands" }, + { kr: "벨기에", en: "Belgium" }, + { kr: "러시아", en: "Russia" }, + { kr: "폴란드", en: "Poland" }, + { kr: "터키", en: "Turkey" }, + { kr: "일본", en: "Japan" }, + { kr: "중국", en: "China" }, + { kr: "인도", en: "India" }, + { kr: "베트남", en: "Vietnam" }, + { kr: "태국", en: "Thailand" }, + { kr: "인도네시아", en: "Indonesia" }, + { kr: "인니", en: "Indonesia" }, + { kr: "필리핀", en: "Philippines" }, + { kr: "말레이시아", en: "Malaysia" }, + { kr: "싱가포르", en: "Singapore" }, + { kr: "호주", en: "Australia" }, + { kr: "대만", en: "Taiwan" }, + { kr: "사우디", en: "Saudi Arabia" }, + { kr: "uae", en: "UAE" }, + { kr: "이집트", en: "Egypt" }, + { kr: "남아공", en: "South Africa" }, + { kr: "나이지리아", en: "Nigeria" }, + { kr: "우즈베키스탄", en: "Uzbekistan" }, + { kr: "카자흐스탄", en: "Kazakhstan" }, + { kr: "아제르바이잔", en: "Azerbaijan" }, + { kr: "이란", en: "Iran" }, + { kr: "이라크", en: "Iraq" }, + { kr: "파키스탄", en: "Pakistan" }, + { kr: "방글라데시", en: "Bangladesh" }, + { kr: "스리랑카", en: "Sri Lanka" }, + { kr: "미얀마", en: "Myanmar" }, + { kr: "캄보디아", en: "Cambodia" }, + { kr: "라오스", en: "Laos" }, + { kr: "몽골", en: "Mongolia" }, + { kr: "이스라엘", en: "Israel" }, + { kr: "스웨덴", en: "Sweden" }, + { kr: "핀란드", en: "Finland" }, + { kr: "노르웨이", en: "Norway" }, + { kr: "덴마크", en: "Denmark" }, + { kr: "체코", en: "Czech Republic" }, + { kr: "헝가리", en: "Hungary" }, + { kr: "루마니아", en: "Romania" }, + { kr: "우크라이나", en: "Ukraine" }, + { kr: "포르투갈", en: "Portugal" }, + { kr: "그리스", en: "Greece" }, + { kr: "뉴질랜드", en: "New Zealand" }, + { kr: "남아프리카", en: "South Africa" }, + { kr: "에티오피아", en: "Ethiopia" }, + { kr: "케냐", en: "Kenya" }, + { kr: "가나", en: "Ghana" }, + { kr: "모로코", en: "Morocco" }, + { kr: "튀니지", en: "Tunisia" }, + { kr: "알제리", en: "Algeria" }, +]; + +function includesCountryToken(qLower: string, token: string): boolean { + const t = token.toLowerCase(); + if (/^[a-z\s]+$/.test(t)) { + const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|\\b)${escaped}(?:\\b|$)`, "i").test(qLower); + } + return qLower.includes(t); +} + +function detectCountryFromQuery(qLower: string): string | null { + for (const { kr, en } of COUNTRY_QUERY_MAP) { + if (includesCountryToken(qLower, kr) || includesCountryToken(qLower, en)) { + return en; + } + } + return null; +} + function buildTavilyQuery(originalQuery: string, intent: string): string { const qL = originalQuery.toLowerCase();