Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.document.application.dto.EmbeddingSearchCommand;
import com.stackup.stackup.document.application.dto.EmbeddingSearchResult;
import com.stackup.stackup.document.domain.DocumentEmbeddingRepository;
import java.util.LinkedHashSet;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

private static final int DEFAULT_TOP_K = 8;
private static final int MAX_TOP_K = 20;
private static final int EMBEDDING_DIMENSION = 1536;

private final DocumentEmbeddingRepository embeddingRepository;

public List<EmbeddingSearchResult> search(EmbeddingSearchCommand command) {
SearchCriteria criteria = validate(command);

if (!embeddingRepository.existsActiveSession(criteria.sessionId())) {
throw new DomainException(ApiErrorCode.SESSION_NOT_FOUND);
}

int searchableDocumentCount = embeddingRepository.countSearchableSessionDocuments(
criteria.sessionId(),
criteria.documentIds()
);
if (searchableDocumentCount != criteria.documentIds().size()) {
throw new DomainException(ApiErrorCode.SESSION_FORBIDDEN);
}

return embeddingRepository.search(
criteria.sessionId(),
criteria.documentIds(),
criteria.queryEmbedding(),
criteria.topK(),
criteria.minScore()
)
.stream()
.map(row -> new EmbeddingSearchResult(
row.documentId(),
row.chunkIndex(),
row.chunkText(),
row.score(),
row.model()
))
.toList();
}

private SearchCriteria validate(EmbeddingSearchCommand command) {
if (command == null || command.sessionId() == null) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}
if (command.documentIds() == null || command.documentIds().isEmpty()) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}
if (command.queryEmbedding() == null || command.queryEmbedding().length == 0) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}
if (command.queryEmbedding().length != EMBEDDING_DIMENSION) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}
if (command.minScore() != null && (command.minScore() < 0.0 || command.minScore() > 1.0)) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}

int topK = command.topK() == null ? DEFAULT_TOP_K : command.topK();
if (topK <= 0 || topK > MAX_TOP_K) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}

List<Long> documentIds = command.documentIds()
.stream()
.filter(id -> id != null && id > 0)
.collect(java.util.stream.Collectors.collectingAndThen(
java.util.stream.Collectors.toCollection(LinkedHashSet::new),
List::copyOf
));
if (documentIds.isEmpty()) {
throw new DomainException(ApiErrorCode.EMBEDDING_BAD_REQUEST);
}

return new SearchCriteria(
command.sessionId(),
documentIds,
command.queryEmbedding(),
topK,
command.minScore()
);
}

private record SearchCriteria(
long sessionId,
List<Long> documentIds,
float[] queryEmbedding,
int topK,
Double minScore
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.stackup.stackup.document.application.dto;

import java.util.List;

public record EmbeddingSearchCommand(
Long sessionId,
List<Long> documentIds,
float[] queryEmbedding,
Integer topK,
Double minScore
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stackup.stackup.document.application.dto;

public record EmbeddingSearchResult(
long documentId,
int chunkIndex,
String chunkText,
double score,
String model
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ public interface DocumentEmbeddingRepository {

int countByDocumentId(long documentId);

boolean existsActiveSession(long sessionId);

int countSearchableSessionDocuments(long sessionId, List<Long> documentIds);

List<EmbeddingSearchRow> search(long sessionId, List<Long> documentIds, float[] queryEmbedding, int topK, Double minScore);

record EmbeddingChunk(int chunkIndex, String chunkText, float[] embedding) {
}

record EmbeddingSearchRow(long documentId, int chunkIndex, String chunkText, double score, String model) {
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.stackup.stackup.document.infrastructure;

import com.stackup.stackup.document.domain.DocumentEmbeddingRepository;
import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.EmbeddingSearchRow;
import java.sql.PreparedStatement;
import java.util.Collections;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
Expand All @@ -25,6 +27,24 @@ ON CONFLICT (document_id, chunk_index)
private static final String COUNT_SQL =
"SELECT count(*) FROM document_embeddings WHERE document_id = ?";

private static final String EXISTS_ACTIVE_SESSION_SQL = """
SELECT EXISTS (
SELECT 1
FROM interview_sessions
WHERE id = ? AND is_deleted = FALSE
)
""";

private static final String COUNT_SEARCHABLE_DOCUMENTS_SQL = """
SELECT COUNT(DISTINCT ad.id)
FROM session_contexts sc
JOIN analyzed_documents ad ON ad.id = sc.document_id
WHERE sc.session_id = ?
AND ad.id = ANY(?::bigint[])
AND ad.is_deleted = FALSE
AND ad.analysis_status = 'ANALYZED'
""";

private final JdbcTemplate jdbc;

public JdbcDocumentEmbeddingRepository(DataSource dataSource) {
Expand Down Expand Up @@ -59,6 +79,87 @@ public int countByDocumentId(long documentId) {
return n == null ? 0 : n;
}

@Override
public boolean existsActiveSession(long sessionId) {
Boolean exists = jdbc.queryForObject(EXISTS_ACTIVE_SESSION_SQL, Boolean.class, sessionId);
return Boolean.TRUE.equals(exists);
}

@Override
public int countSearchableSessionDocuments(long sessionId, List<Long> documentIds) {
if (documentIds == null || documentIds.isEmpty()) {
return 0;
}
Integer count = jdbc.query(
COUNT_SEARCHABLE_DOCUMENTS_SQL,
ps -> {
ps.setLong(1, sessionId);
ps.setArray(2, ps.getConnection().createArrayOf("bigint", documentIds.toArray(Long[]::new)));
},
rs -> rs.next() ? rs.getInt(1) : 0
);
return count == null ? 0 : count;
}

@Override
public List<EmbeddingSearchRow> search(
long sessionId,
List<Long> documentIds,
float[] queryEmbedding,
int topK,
Double minScore
) {
if (documentIds == null || documentIds.isEmpty()) {
return Collections.emptyList();
}

String queryVector = toVectorLiteral(queryEmbedding);
String sql = """
SELECT document_id, chunk_index, chunk_text, score, model
FROM (
SELECT
de.document_id,
de.chunk_index,
de.chunk_text,
1 - (de.embedding <=> ?::vector) AS score,
de.model,
de.embedding <=> ?::vector AS distance
FROM document_embeddings de
JOIN session_contexts sc
ON sc.document_id = de.document_id
AND sc.session_id = ?
JOIN analyzed_documents ad
ON ad.id = de.document_id
WHERE ad.is_deleted = FALSE
AND ad.analysis_status = 'ANALYZED'
AND de.document_id = ANY(?::bigint[])
) ranked
WHERE (? IS NULL OR score >= ?)
ORDER BY distance
LIMIT ?
""";

return jdbc.query(
sql,
ps -> {
ps.setString(1, queryVector);
ps.setString(2, queryVector);
ps.setLong(3, sessionId);
ps.setArray(4, ps.getConnection().createArrayOf("bigint", documentIds.toArray(Long[]::new)));
ps.setObject(5, minScore);
ps.setObject(6, minScore);
ps.setInt(7, topK);
},
(rs, rowNum) -> new EmbeddingSearchRow(
rs.getLong("document_id"),
rs.getInt("chunk_index"),
rs.getString("chunk_text"),
rs.getDouble("score"),
rs.getString("model")
)
);
}

private static String toVectorLiteral(float[] embedding) {
StringBuilder sb = new StringBuilder(embedding.length * 8 + 2);
sb.append('[');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.stackup.stackup.document.presentation;

import com.stackup.stackup.document.application.DocumentEmbeddingSearchService;
import com.stackup.stackup.document.presentation.dto.InternalEmbeddingSearchRequest;
import com.stackup.stackup.document.presentation.dto.InternalEmbeddingSearchResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "Internal: Embedding Search", description = "X-Internal-API-Key 필요. AI 서버가 세션 컨텍스트 문서 청크를 pgvector 로 검색.")
@RestController
@RequestMapping("/api/internal/embeddings")
@RequiredArgsConstructor
public class InternalEmbeddingSearchController {

private final DocumentEmbeddingSearchService searchService;

@Operation(
operationId = "internalSearchEmbeddings",
summary = "세션 컨텍스트 문서 임베딩 검색",
description = "AI 서버가 생성한 queryEmbedding 으로 세션에 연결된 분석 완료 문서의 상위 chunk 를 검색합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "검색 성공. 결과가 없으면 빈 results 배열 반환"),
@ApiResponse(responseCode = "400", description = "요청 payload 검증 실패"),
@ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패"),
@ApiResponse(responseCode = "403", description = "요청 문서가 세션 컨텍스트에 없거나 분석 완료 상태가 아님"),
@ApiResponse(responseCode = "404", description = "sessionId 에 해당하는 세션 없음")
})
@PostMapping("/search")
public InternalEmbeddingSearchResponse search(@Valid @RequestBody InternalEmbeddingSearchRequest request) {
return InternalEmbeddingSearchResponse.from(searchService.search(request.toCommand()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.stackup.stackup.document.presentation.dto;

import com.stackup.stackup.document.application.dto.EmbeddingSearchCommand;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;

public record InternalEmbeddingSearchRequest(
@NotNull Long sessionId,
@NotNull @NotEmpty List<Long> documentIds,
@NotNull float[] queryEmbedding,
Integer topK,
Double minScore
) {

public EmbeddingSearchCommand toCommand() {
return new EmbeddingSearchCommand(sessionId, documentIds, queryEmbedding, topK, minScore);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.stackup.stackup.document.presentation.dto;

import com.stackup.stackup.document.application.dto.EmbeddingSearchResult;
import java.util.List;

public record InternalEmbeddingSearchResponse(List<Result> results) {

public static InternalEmbeddingSearchResponse from(List<EmbeddingSearchResult> results) {
return new InternalEmbeddingSearchResponse(
results.stream()
.map(Result::from)
.toList()
);
}

public record Result(
long documentId,
int chunkIndex,
String chunkText,
double score,
String model
) {

private static Result from(EmbeddingSearchResult result) {
return new Result(
result.documentId(),
result.chunkIndex(),
result.chunkText(),
result.score(),
result.model()
);
}
}
}
Loading