Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3b388d2
chore: 원활한 테스트 진행을 위해 rate limiter 설정 완화
Dimo-2562 Feb 6, 2026
83418e8
fix: ScrapPost 중복 제거
Dimo-2562 Feb 6, 2026
e9c1f93
improve: 테스트 결과에 따라 가중치 변경
Dimo-2562 Feb 6, 2026
797b989
refactor: Retrieval에서는 좋은 후보군 추출을 위해 랜덤성 제거
Dimo-2562 Feb 6, 2026
b79fa3f
refactor: 읽은 글 제외 쿼리 생성 메서드를 VectorQueryBuilder로 이동
Dimo-2562 Feb 6, 2026
bb41f04
chore: 메서드 위치 변경
Dimo-2562 Feb 6, 2026
4fe2283
refactor: 평가를 위한 추천 메서드를 test 패키지로 분리
Dimo-2562 Feb 6, 2026
7a37813
improve: LLM 프로필 생성 시 키워드도 추출하도록 변경
Dimo-2562 Feb 6, 2026
e27eeb1
improve: 추천 로직에서 BM25 검색도 진행한 뒤 RRF로 결합
Dimo-2562 Feb 6, 2026
ee65ac3
refactor: RRF 로직을 공통 클래스에서 수행하도록 변경
Dimo-2562 Feb 6, 2026
432fc1a
fix: ScrapPost 생성 시 readPosts의 중복을 제거하여 unique 제약 만족
Dimo-2562 Feb 6, 2026
bfa7c3c
improve: LlmRecommendationService 같은 이름의 빈 2개 해결을 위해 Primary 어노테이션 추가
Dimo-2562 Feb 6, 2026
628ecec
improve: keyKeywords를 테스트 유저 프로필 생성 로직에 추가
Dimo-2562 Feb 6, 2026
85eccf2
refactor: BM25 쿼리랑 kNN 쿼리를 병렬적으로 수행하도록 변경
Dimo-2562 Feb 6, 2026
5d9e0bf
improve: 콘텐츠 쳥크에 대해서 BM25 검색 할 때 scoreMode를 Max로 지정
Dimo-2562 Feb 6, 2026
b41c19f
refactor: mmr에 넘기는 후보군의 수를 80개로 조정
Dimo-2562 Feb 6, 2026
439f752
improve: MMR 알고리즘에서 top-k 샘플링으로 랜덤성 도입
Dimo-2562 Feb 6, 2026
d8f8564
improve: BM25 검색에서 결과가 null일때 처리 추가
Dimo-2562 Feb 6, 2026
6794659
test: 테스트 컨테이너 중 ES의 메모리를 2GB로 설정
Dimo-2562 Feb 6, 2026
bea94c1
improve: 추천 평가 테스트 중 각 단계의 소요시간 확인
Dimo-2562 Feb 6, 2026
4cff1b5
improve: 추천 소요 시간 단축을 위해 후보군 조회 사이즈 변경
Dimo-2562 Feb 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
@ConfigurationProperties(prefix = "recommendation")
public class RecommendationProperties {

private Integer knnSearchSize = 100;
private Integer knnSearchSize = 80;

private Integer numCandidates = 200;
private Integer numCandidates = 180;

private Integer mmrCandidateSize = 80;

private Integer mmrFinalSize = 30;

Expand All @@ -34,9 +36,9 @@ public class RecommendationProperties {
@NoArgsConstructor
@AllArgsConstructor
public static class EmbeddingWeights {
private Float title = 0.5f;
private Float summary = 0.5f;
private Float content = 0.0f;
private Float title = 0.4f;
private Float summary = 0.4f;
private Float content = 0.2f;
}

@Getter
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
* MMR (Maximal Marginal Relevance) 알고리즘 구현
Expand All @@ -21,6 +22,7 @@
public class MmrService {

private final RecommendationProperties properties;
private final Random random = new Random();

@Getter
@Builder
Expand All @@ -40,6 +42,18 @@ public static class MmrResult {
private int rank;
}

private static class ScoredCandidate {
MmrCandidate candidate;
double mmrScore;
int originalIndex;

ScoredCandidate(MmrCandidate candidate, double mmrScore, int originalIndex) {
this.candidate = candidate;
this.mmrScore = mmrScore;
this.originalIndex = originalIndex;
}
}

/**
* MMR 알고리즘을 적용하여 다양성을 보장하는 추천 결과 생성
*
Expand All @@ -61,41 +75,42 @@ public List<MmrResult> applyMmr(List<MmrCandidate> candidates) {
log.debug("MMR 선택 시작: candidates={}, finalSize={}, lambda={}",
candidates.size(), finalSize, lambda);

// 첫 번째는 가장 유사도가 높은 문서 선택
MmrCandidate first = remainingCandidates.remove(0);
// 첫 번째는 상위 K개 중에서 랜덤하게 선택 (다양성 증가)
int topK = Math.min(5, remainingCandidates.size());
int randomIndex = random.nextInt(topK);
MmrCandidate first = remainingCandidates.remove(randomIndex);
selectedResults.add(MmrResult.builder()
.postId(first.getPostId())
.similarityScore(first.getSimilarityScore())
.mmrScore(first.getSimilarityScore())
.rank(1)
.build());

// 나머지 문서들을 MMR 점수 기반으로 선택
// 나머지 문서들을 MMR 점수 기반으로 선택 (Top-K 샘플링으로 랜덤성 추가)
while (selectedResults.size() < finalSize && !remainingCandidates.isEmpty()) {
MmrCandidate bestCandidate = null;
double bestMmrScore = Double.NEGATIVE_INFINITY;
int bestIndex = -1;

// 모든 후보의 MMR 점수 계산
List<ScoredCandidate> scoredCandidates = new ArrayList<>();
for (int i = 0; i < remainingCandidates.size(); i++) {
MmrCandidate candidate = remainingCandidates.get(i);
double mmrScore = calculateMmrScore(candidate, selectedResults, lambda, candidates);

if (mmrScore > bestMmrScore) {
bestMmrScore = mmrScore;
bestCandidate = candidate;
bestIndex = i;
}
scoredCandidates.add(new ScoredCandidate(candidate, mmrScore, i));
}

if (bestCandidate != null) {
remainingCandidates.remove(bestIndex);
selectedResults.add(MmrResult.builder()
.postId(bestCandidate.getPostId())
.similarityScore(bestCandidate.getSimilarityScore())
.mmrScore(bestMmrScore)
.rank(selectedResults.size() + 1)
.build());
}
// MMR 점수 내림차순 정렬
scoredCandidates.sort((a, b) -> Double.compare(b.mmrScore, a.mmrScore));

// 상위 K개 중에서 랜덤 선택
int topKForSelection = Math.min(3, scoredCandidates.size());
int randomIdx = random.nextInt(topKForSelection);
ScoredCandidate selected = scoredCandidates.get(randomIdx);

remainingCandidates.remove(selected.originalIndex);
selectedResults.add(MmrResult.builder()
.postId(selected.candidate.getPostId())
.similarityScore(selected.candidate.getSimilarityScore())
.mmrScore(selected.mmrScore)
.rank(selectedResults.size() + 1)
.build());
}

log.info("MMR 선택 완료: 전체 {} 후보 중 {} 개 선택",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.techfork.domain.user.document.UserProfileDocument;
import com.techfork.domain.user.repository.UserProfileDocumentRepository;
import com.techfork.global.llm.EmbeddingClient;
import com.techfork.global.util.RrfScorer;
import com.techfork.global.util.VectorUtil;
import java.io.IOException;
import java.util.ArrayList;
Expand Down Expand Up @@ -245,21 +246,29 @@ private KnnSearch createKnnSearch(String field, List<Float> vector, int k, int n
}

private List<SearchResult> calculateRRF(List<Hit<PostDocument>> lexicalHits, List<Hit<PostDocument>> semanticHits) {
Map<String, Integer> lexicalRankMap = new HashMap<>();
AtomicInteger rank = new AtomicInteger(1);
lexicalHits.forEach(hit -> lexicalRankMap.put(hit.id(), rank.getAndIncrement()));

Map<String, Integer> semanticRankMap = new HashMap<>();
rank.set(1);
semanticHits.forEach(hit -> semanticRankMap.put(hit.id(), rank.getAndIncrement()));

Map<String, SearchResult> combinedResults = new HashMap<>();
Map<String, Double> rrfScores = new HashMap<>();

processHitsForRRF(lexicalHits, lexicalRankMap, rrfScores, combinedResults);
processHitsForRRF(semanticHits, semanticRankMap, rrfScores, combinedResults);
// Hit ID 리스트 추출
List<String> lexicalIds = lexicalHits.stream().map(Hit::id).toList();
List<String> semanticIds = semanticHits.stream().map(Hit::id).toList();

// RRF 스코어 계산
Map<String, Double> rrfScores = RrfScorer.calculateRrfScores(lexicalIds, semanticIds);

// Hit을 docId 기준으로 맵핑 (semantic 우선 - 벡터 포함 보장)
Map<String, Hit<PostDocument>> hitMap = new HashMap<>();
lexicalHits.forEach(hit -> hitMap.put(hit.id(), hit));
semanticHits.forEach(hit -> hitMap.put(hit.id(), hit)); // semantic 결과로 덮어쓰기 (벡터 포함)

// SearchResult로 변환
Map<String, SearchResult> resultMap = new HashMap<>();
for (Map.Entry<String, Hit<PostDocument>> entry : hitMap.entrySet()) {
String docId = entry.getKey();
Hit<PostDocument> hit = entry.getValue();
SearchResult result = mapToSearchResult(hit);
resultMap.put(docId, result);
}

return combinedResults.values().stream()
// 최종 스코어 적용 및 정렬
return resultMap.values().stream()
.map(searchResult -> {
double finalScore = rrfScores.get(searchResult.getPostId().toString());
return searchResult.toBuilder()
Expand All @@ -272,41 +281,6 @@ private List<SearchResult> calculateRRF(List<Hit<PostDocument>> lexicalHits, Lis
.collect(Collectors.toList());
}

private void processHitsForRRF(List<Hit<PostDocument>> hits,
Map<String, Integer> rankMap,
Map<String, Double> rrfScores,
Map<String, SearchResult> combinedResults) {
hits.forEach(hit -> {
String docId = hit.id();
double score = 1.0 / (generalSearchProperties.getRRF_K() + rankMap.get(docId));
rrfScores.merge(docId, score, Double::sum);

SearchResult newResult = mapToSearchResult(hit);

if (!combinedResults.containsKey(docId)) {
combinedResults.put(docId, newResult);
} else {
SearchResult existing = combinedResults.get(docId);
boolean needUpdate = false;
SearchResult.SearchResultBuilder builder = existing.toBuilder();

if (existing.getTitleVector() == null && newResult.getTitleVector() != null) {
builder.titleVector(newResult.getTitleVector());
needUpdate = true;
}

if (existing.getSummaryVector() == null && newResult.getSummaryVector() != null) {
builder.summaryVector(newResult.getSummaryVector());
needUpdate = true;
}

if (needUpdate) {
combinedResults.put(docId, builder.build());
}
}
});
}

private SearchResult mapToSearchResult(Hit<PostDocument> hit) {
PostDocument doc = hit.source();
double score = Objects.requireNonNullElse(hit.score(), 0.0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,33 @@ public class UserProfileDocument {
@Field(type = FieldType.Keyword)
private List<String> interests;

@Field(type = FieldType.Keyword)
private List<String> keyKeywords;

@Field(type = FieldType.Date)
@Transient
private LocalDateTime generatedAt;

@Builder
private UserProfileDocument(Long userId, String profileText, float[] profileVector,
List<String> interests, LocalDateTime generatedAt) {
List<String> interests, List<String> keyKeywords, LocalDateTime generatedAt) {
this.id = String.valueOf(userId);
this.userId = userId;
this.profileText = profileText;
this.profileVector = profileVector;
this.interests = interests;
this.keyKeywords = keyKeywords;
this.generatedAt = generatedAt;
}

public static UserProfileDocument create(Long userId, String profileText, float[] profileVector,
List<String> interests) {
List<String> interests, List<String> keyKeywords) {
return UserProfileDocument.builder()
.userId(userId)
.profileText(profileText)
.profileVector(profileVector)
.interests(interests)
.keyKeywords(keyKeywords)
.generatedAt(LocalDateTime.now())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -57,14 +58,17 @@ public void generateUserProfile(Long userId) {
public void generateUserProfileSync(Long userId) {
try {
UserActivityData activityData = collectUserActivityData(userId);
String profileText = generateProfileTextWithLLM(activityData);
float[] profileVector = generateEmbeddingVector(profileText);
String llmResponse = generateProfileTextWithLLM(activityData);

ProfileAndKeywords parsed = parseProfileAndKeywords(llmResponse);
float[] profileVector = generateEmbeddingVector(parsed.profileText);

UserProfileDocument profileDocument = UserProfileDocument.create(
userId,
profileText,
parsed.profileText,
profileVector,
activityData.interests
activityData.interests,
parsed.keyKeywords
);

userProfileDocumentRepository.save(profileDocument);
Expand Down Expand Up @@ -143,7 +147,7 @@ private String generateProfileTextWithLLM(UserActivityData data) {

private String buildProfileGenerationPrompt(UserActivityData data) {
return String.format("""
아래 사용자의 활동 데이터를 분석하여 검색 고도화와 포스트 추천에 최적화된 프로필을 생성해주세요.
아래 사용자의 활동 데이터를 분석하여 검색 리랭킹과 포스트 추천에 최적화된 프로필을 생성해주세요.

## 사용자 데이터

Expand All @@ -161,28 +165,29 @@ private String buildProfileGenerationPrompt(UserActivityData data) {

## 요구사항

다음 형식으로 구조화된 프로필을 생성해주세요:

1. **기술적 관심사 요약** (2-3문장)
- 사용자가 주로 관심을 갖는 기술 스택, 프레임워크, 도구
- 선호하는 개발 분야 (백엔드, 프론트엔드, AI, 인프라 등)
반드시 아래 형식으로 응답해주세요:

2. **콘텐츠 선호 패턴** (2-3문장)
- 읽은 포스트와 스크랩한 포스트를 분석하여 선호하는 주제와 기술 파악
- 선호하는 회사/팀이나 콘텐츠 유형 (튜토리얼, 아키텍처, 트러블슈팅 등)
### PROFILE
사용자의 기술적 관심사, 학습 패턴, 선호도를 의미 밀도 높고 풍부하게 표현한 텍스트를 작성하세요 (200-300자 정도).

3. **검색 의도 분석** (2-3문장)
- 검색 기록에서 드러나는 학습 목적이나 해결하려는 문제
- 반복되는 검색 주제나 패턴
다음 내용을 모두 포함하되 자연스러운 문장으로 작성:
1. 주요 관심 기술 스택과 개발 분야 (백엔드/프론트엔드/인프라/AI 등)
2. 선호하는 주제와 학습 방향 (아키텍처 설계, 성능 최적화, 트러블슈팅, 신기술 탐구 등)
3. 읽은 포스트와 검색 기록에서 드러나는 구체적인 관심사
4. 현재 해결하려는 문제나 학습 중인 영역
5. 콘텐츠 선호 패턴 (심화 기술, 실전 경험, 튜토리얼 등)

4. **추천 키워드** (쉼표로 구분된 15-20개의 키워드)
- 검색 쿼리 확장에 사용할 관련 기술 용어
- 유사한 관심사를 가진 사용자가 찾을 만한 키워드
- 영문과 한글 키워드 모두 포함
주의사항:
- 마크다운 없이 순수 텍스트로만 작성 (볼드, 이탤릭, 리스트, 번호 금지)
- 구체적인 기술 용어를 많이 사용하여 임베딩 품질 향상
- "관심이 있습니다", "선호합니다" 같은 메타 표현 대신 직접적인 기술 용어 나열

5. **프로필 요약** (1-2문장, 벡터 임베딩 최적화용)
- 사용자의 기술적 페르소나를 한 줄로 압축
- 추천 시스템이 유사 사용자를 찾는데 활용할 핵심 설명
### KEYWORDS
사용자의 현재 관심사를 가장 잘 대표하는 핵심 키워드 3-5개를 쉼표로 구분하여 나열하세요.
- 구체적이고 검색 의도가 명확한 키워드만 선택
- BM25 검색에 사용되므로 검색어로 자주 쓰일 만한 용어 선택
- 예: Kubernetes, React hooks, 분산 트랜잭션, 성능 최적화, MSA
- 영문과 한글 혼용 가능

데이터가 부족한 경우 관심 기술 스택을 기반으로 일반적인 프로필을 생성해주세요.
""",
Expand Down Expand Up @@ -247,6 +252,43 @@ private String convertReadingDurationToNaturalLanguage(Integer durationSeconds)
}
}

private ProfileAndKeywords parseProfileAndKeywords(String llmResponse) {
String profileText = "";
List<String> keyKeywords = List.of();

try {
// PROFILE 섹션 추출
int profileStart = llmResponse.indexOf("### PROFILE");
int keywordsStart = llmResponse.indexOf("### KEYWORDS");

if (profileStart != -1 && keywordsStart != -1) {
profileText = llmResponse.substring(profileStart + "### PROFILE".length(), keywordsStart)
.trim();

String keywordsSection = llmResponse.substring(keywordsStart + "### KEYWORDS".length())
.trim();

// 쉼표로 구분된 키워드 파싱
keyKeywords = Arrays.stream(keywordsSection.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.limit(5) // 최대 5개
.toList();
} else {
// 파싱 실패 시 전체 텍스트를 프로필로 사용
log.warn("Failed to parse LLM response sections, using full text as profile");
profileText = llmResponse;
}
} catch (Exception e) {
log.error("Error parsing LLM response", e);
profileText = llmResponse;
}

return new ProfileAndKeywords(profileText, keyKeywords);
}

private record ProfileAndKeywords(String profileText, List<String> keyKeywords) {}

private record UserActivityData(
List<String> interests,
List<PostData> readPostData,
Expand Down
Loading
Loading