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
42 changes: 42 additions & 0 deletions back/src/main/java/com/back/domain/search/entity/AgeTheme.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* 이 파일은 연령대+카테고리 테마 사전 엔티티를 정의한다.
* 흐름: 테마 문자열/임베딩 보관 → min_age~max_age 및 category로 필터링
*/
package com.back.domain.search.entity;

import com.back.domain.node.entity.NodeCategory;
import com.back.infra.pgvector.PgVectorConverter;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Entity
@Table(name = "age_theme")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class AgeTheme {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

@Column(name = "min_age", nullable = false)
private int minAge;

@Column(name = "max_age", nullable = false)
private int maxAge;

@Enumerated(EnumType.STRING)
@Column(name = "category", nullable = false, length = 32)
private NodeCategory category;

@Column(name = "theme", nullable = false, columnDefinition = "text")
private String theme;

@JdbcTypeCode(SqlTypes.OTHER)
@Convert(converter = PgVectorConverter.class)
@Column(name = "embedding", nullable = false, columnDefinition = "vector(768)")
private float[] embedding;
}
31 changes: 31 additions & 0 deletions back/src/main/java/com/back/domain/search/entity/VocabTerm.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* [코드 흐름 요약]
* - '도메인 용어 사전'을 pgvector로 관리하기 위한 엔티티.
* - term(용어)와 embedding(vector)을 저장한다.
* - 검색은 임베딩 유사도(<=>)로 수행한다.
*/
package com.back.domain.search.entity;

import com.back.infra.pgvector.PgVectorConverter;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;

@Entity
@Table(name = "vocab_term")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class VocabTerm {

@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "term", nullable = false, unique = true, length = 128)
private String term;

@JdbcTypeCode(SqlTypes.OTHER)
@Convert(converter = PgVectorConverter.class)
@Column(name = "embedding", nullable = false, columnDefinition = "vector(768)")
private float[] embedding;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* [코드 흐름 요약]
* - age 범위 + (선택) category로 필터하고 pgvector 유사도로 상위 K 테마를 가져온다.
* - 시더 중복 방지용으로 기존 테마 문자열을 조회하는 메서드를 제공한다.
*/
package com.back.domain.search.repository;

import com.back.domain.node.entity.NodeCategory;
import com.back.domain.search.entity.AgeTheme;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface AgeThemeRepository extends JpaRepository<AgeTheme, Long> {

// next 노드 생성
@Query(value = """
SELECT theme FROM age_theme
WHERE :age BETWEEN min_age AND max_age
AND (:cat IS NULL OR category = :cat)
ORDER BY embedding <=> CAST(:q AS vector)
LIMIT :k
""", nativeQuery = true)
List<String> topKThemesByAgeAndCategory(
@Param("age") int age,
@Param("cat") String categoryOrNull, // Enum이면 cat.name()으로 전달
@Param("q") String vectorLiteral,
@Param("k") int k
);

// 무결성 검증
long countByMinAge(int minAge);
long countByMinAgeAndCategory(int minAge, NodeCategory category);

// 중복 방지용(시더에서 필요할 때만 사용)
@Query("select a.theme from AgeTheme a where a.minAge = :minAge and a.category = :category")
List<String> findThemesByMinAgeAndCategory(@Param("minAge") int minAge,
@Param("category") NodeCategory category);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* [코드 흐름 요약]
* - 쿼리 임베딩과 가까운 용어를 유사도(<=>) 순으로 상위 K개 조회한다.
* - 임베딩은 문자열 리터럴(CAST(:q AS vector))로 전달한다.
*/
package com.back.domain.search.repository;

import com.back.domain.search.entity.VocabTerm;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface VocabTermRepository extends JpaRepository<VocabTerm, Long> {

@Query(value = """
SELECT * FROM vocab_term
ORDER BY embedding <=> CAST(:q AS vector)
LIMIT :k
""", nativeQuery = true)
List<VocabTerm> searchTopK(
@Param("q") String vectorLiteral,
@Param("k") int k
);

@Query(value = """
SELECT term FROM vocab_term
ORDER BY embedding <=> CAST(:q AS vector)
LIMIT :k
""", nativeQuery = true)
List<String> searchTopKTerms(
@Param("q") String vectorLiteral,
@Param("k") int k
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* [코드 흐름 요약]
* - 최초 워밍업: 30초 간격 10회(≈5분) → 이후 5분 간격 전환(기존 유지).
* - 각 틱마다 age 커서를 1씩 이동하며 ensureSeedForAgeAsync(age, MIN_PER_CATEGORY) 호출(기존 유지).
* - 10만건 목표를 위해 MIN_PER_CATEGORY=120, 연령 구간을 3~120으로 확장.
*/
package com.back.global.ai.bootstrap;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.concurrent.atomic.AtomicInteger;

@Component
@Profile("dev")
@RequiredArgsConstructor
public class AgeThemePreseedScheduler {

private final AgeThemeSeeder seeder;

// 무결성 검증
private static final int WARMUP_RUNS = 10; // 30초 간격으로 10회
private static final int MIN_PER_CATEGORY = 120; // 카테고리당 최소 시드 개수(확대)
private static final int MIN_AGE = 3; // 순회 시작 연령(확장)
private static final int MAX_AGE = 120; // 순회 종료 연령(확장)

private final AtomicInteger warmupCount = new AtomicInteger(0);
private final AtomicInteger ageCursor = new AtomicInteger(MIN_AGE);

// next 노드 생성
@Scheduled(initialDelay = 5_000, fixedRate = 30_000) // 첫 실행 5초 후, 30초 주기
public void warmupPhase() {
int n = warmupCount.get();
if (n >= WARMUP_RUNS) return; // 무결성 검증
tick();
warmupCount.incrementAndGet();
}

// 무결성 검증
@Scheduled(initialDelay = 5 * 60_000, fixedRate = 5 * 60_000) // 5분 주기
public void steadyPhase() {
if (warmupCount.get() < WARMUP_RUNS) return; // 워밍업 완료 전엔 대기
tick();
}

// next 노드 생성
private void tick() {
int age = nextAge();
seeder.ensureSeedForAgeAsync(age, MIN_PER_CATEGORY);
}

// 무결성 검증
private int nextAge() {
int cur = ageCursor.getAndUpdate(v -> (v >= MAX_AGE) ? MIN_AGE : (v + 1));
return cur;
}
}
Loading