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
16 changes: 16 additions & 0 deletions ops/db/migrations/20260730_job_posting_embeddings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS job_posting_embeddings (
id BIGSERIAL PRIMARY KEY,
job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE,
embedding_model VARCHAR(100) NOT NULL,
embedding vector(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting
ON job_posting_embeddings (job_posting_id);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw
ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops);
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.jobdri.jobdri_api.domain.jobposting.dto.response;

public record JobPostingSimilarityResult(
Long jobPostingId,
String postingName,
String companyName,
String jobTitle,
double similarityScore
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import java.util.List;

public interface JobPostingRepository extends JpaRepository<JobPosting, Long> {
List<JobPosting> findAllByOrderByIdAsc();

@EntityGraph(attributePaths = {
"company",
"user",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class JobPostingEmbeddingService {

private final JobPostingEmbeddingTextBuilder textBuilder;
private final CohereEmbeddingClient cohereEmbeddingClient;

public float[] embed(JobPosting jobPosting) {
List<float[]> embeddings = embedAll(List.of(jobPosting));
return embeddings.getFirst();
}

public List<float[]> embedAll(List<JobPosting> jobPostings) {
List<String> texts = jobPostings.stream()
.map(textBuilder::build)
.toList();
return cohereEmbeddingClient.embedDocuments(texts);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import com.jobdri.jobdri_api.global.cohere.CohereProperties;
import com.pgvector.PGvector;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class JobPostingEmbeddingSyncService {

private static final String UPSERT_JOB_POSTING_SQL = """
INSERT INTO job_posting_embeddings (job_posting_id, embedding_model, embedding, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (job_posting_id)
DO UPDATE SET
embedding_model = EXCLUDED.embedding_model,
embedding = EXCLUDED.embedding,
updated_at = EXCLUDED.updated_at
""";

@Value("${app.corpus.embedding.batch-size:32}")
private int batchSize;

private final JobPostingRepository jobPostingRepository;
private final JobPostingEmbeddingService jobPostingEmbeddingService;
private final CohereProperties cohereProperties;
private final DataSource dataSource;

@Transactional
public int syncJobPosting(Long jobPostingId) {
JobPosting jobPosting = jobPostingRepository.findById(jobPostingId)
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.JOB_POSTING_NOT_FOUND,
"해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId
));
upsertJobPostingEmbeddings(List.of(jobPosting));
return 1;
}

@Transactional
public int syncAllJobPostings() {
return upsertJobPostingEmbeddings(jobPostingRepository.findAllByOrderByIdAsc());
}

private int upsertJobPostingEmbeddings(List<JobPosting> jobPostings) {
int processed = 0;
for (List<JobPosting> batch : partition(jobPostings, batchSize)) {
List<float[]> embeddings = jobPostingEmbeddingService.embedAll(batch);
upsertVectors(batch.stream().map(JobPosting::getId).toList(), embeddings);
processed += batch.size();
}
return processed;
}

private void upsertVectors(List<Long> jobPostingIds, List<float[]> embeddings) {
if (jobPostingIds.size() != embeddings.size()) {
throw new IllegalStateException("임베딩 결과 개수가 채용 공고 개수와 일치하지 않습니다.");
}

Connection connection = DataSourceUtils.getConnection(dataSource);
try {
PGvector.registerTypes(connection);
try (PreparedStatement statement = connection.prepareStatement(UPSERT_JOB_POSTING_SQL)) {
Timestamp now = Timestamp.valueOf(LocalDateTime.now());
for (int i = 0; i < jobPostingIds.size(); i++) {
statement.setLong(1, jobPostingIds.get(i));
statement.setString(2, cohereProperties.embedding().model());
statement.setObject(3, new PGvector(embeddings.get(i)));
statement.setTimestamp(4, now);
statement.setTimestamp(5, now);
statement.addBatch();
}
statement.executeBatch();
}
} catch (SQLException e) {
throw new IllegalStateException("채용 공고 임베딩 벡터 저장 중 오류가 발생했습니다.", e);
} finally {
DataSourceUtils.releaseConnection(connection, dataSource);
}
}

private <T> List<List<T>> partition(List<T> items, int batchSize) {
List<List<T>> result = new ArrayList<>();
if (items.isEmpty()) {
return result;
}
int actualBatchSize = Math.max(1, batchSize);
for (int i = 0; i < items.size(); i += actualBatchSize) {
result.add(items.subList(i, Math.min(items.size(), i + actualBatchSize)));
}
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

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

@Component
public class JobPostingEmbeddingTextBuilder {

public String build(JobPosting jobPosting) {
List<String> sections = new ArrayList<>();
addSection(sections, "직무", jobPosting.getJobTitle());
addSection(sections, "주요업무", jobPosting.getTask());
addSection(sections, "자격요건", jobPosting.getRequirement());
addSection(sections, "우대사항", jobPosting.getPreferred());
return String.join("\n\n", sections);
}

private void addSection(List<String> sections, String title, String value) {
if (!StringUtils.hasText(value)) {
return;
}
sections.add(title + "\n" + value.trim());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.jobdri.jobdri_api.domain.jobposting.service;

import com.jobdri.jobdri_api.domain.jobposting.dto.response.JobPostingSimilarityResult;
import com.jobdri.jobdri_api.domain.jobposting.entity.JobPosting;
import com.jobdri.jobdri_api.domain.jobposting.repository.JobPostingRepository;
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
import com.jobdri.jobdri_api.global.cohere.CohereEmbeddingClient;
import com.pgvector.PGvector;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class JobPostingRetrievalService {

private static final int DEFAULT_LIMIT = 3;
private static final String SIMILAR_JOB_POSTINGS_SQL = """
SELECT
jp.id,
jp.posting_name,
c.name AS company_name,
jp.job_title,
e.embedding <=> ? AS distance
FROM job_posting_embeddings e
JOIN job_postings jp ON e.job_posting_id = jp.id
JOIN companies c ON jp.company_id = c.id
WHERE jp.id <> ?
AND jp.user_id = ?
ORDER BY e.embedding <=> ?
LIMIT ?
""";

private final JobPostingRepository jobPostingRepository;
private final JobPostingEmbeddingTextBuilder textBuilder;
private final CohereEmbeddingClient cohereEmbeddingClient;
private final DataSource dataSource;

public List<JobPostingSimilarityResult> findSimilarJobPostings(Long jobPostingId) {
return findSimilarJobPostings(jobPostingId, DEFAULT_LIMIT);
}

public List<JobPostingSimilarityResult> findSimilarJobPostings(Long jobPostingId, int limit) {
JobPosting current = jobPostingRepository.findById(jobPostingId)
.orElseThrow(() -> new GeneralException(
GeneralErrorCode.JOB_POSTING_NOT_FOUND,
"해당 공고를 찾을 수 없습니다. jobPostingId=" + jobPostingId
));
int actualLimit = Math.max(1, limit);
String query = textBuilder.build(current);
float[] vector = cohereEmbeddingClient.embedQuery(query);
return findSimilarJobPostings(current, vector, actualLimit);
}

private List<JobPostingSimilarityResult> findSimilarJobPostings(
JobPosting current,
float[] vector,
int limit
) {
try (Connection connection = dataSource.getConnection()) {
PGvector.registerTypes(connection);
try (PreparedStatement statement = connection.prepareStatement(SIMILAR_JOB_POSTINGS_SQL)) {
statement.setObject(1, new PGvector(vector));
statement.setLong(2, current.getId());
statement.setLong(3, current.getUser().getId());
statement.setObject(4, new PGvector(vector));
statement.setInt(5, limit);
try (ResultSet resultSet = statement.executeQuery()) {
List<JobPostingSimilarityResult> results = new ArrayList<>();
while (resultSet.next()) {
double distance = resultSet.getDouble("distance");
results.add(new JobPostingSimilarityResult(
resultSet.getLong("id"),
resultSet.getString("posting_name"),
resultSet.getString("company_name"),
resultSet.getString("job_title"),
1.0 - distance
));
}
return results;
}
}
} catch (SQLException e) {
throw new IllegalStateException("유사 채용 공고 검색 중 오류가 발생했습니다.", e);
}
}
}
15 changes: 15 additions & 0 deletions src/main/resources/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ CREATE TABLE IF NOT EXISTS mock_question_embeddings (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS job_posting_embeddings (
id BIGSERIAL PRIMARY KEY,
job_posting_id BIGINT NOT NULL UNIQUE REFERENCES job_postings(id) ON DELETE CASCADE,
embedding_model VARCHAR(100) NOT NULL,
embedding vector(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_job_postings_company_detail
ON job_postings (company_id, detail_classification_id);

Expand All @@ -34,12 +43,18 @@ CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_corpus
CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_corpus
ON mock_question_embeddings (corpus_id);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_job_posting
ON job_posting_embeddings (job_posting_id);

CREATE INDEX IF NOT EXISTS idx_mock_job_posting_embeddings_hnsw
ON mock_job_posting_embeddings USING hnsw (embedding vector_cosine_ops);

CREATE INDEX IF NOT EXISTS idx_mock_question_embeddings_hnsw
ON mock_question_embeddings USING hnsw (embedding vector_cosine_ops);

CREATE INDEX IF NOT EXISTS idx_job_posting_embeddings_hnsw
ON job_posting_embeddings USING hnsw (embedding vector_cosine_ops);

ALTER TABLE IF EXISTS analyses
ADD COLUMN IF NOT EXISTS missing_keywords TEXT NOT NULL DEFAULT '[]';

Expand Down
Loading
Loading