Compare commits

..

4 Commits

Author SHA1 Message Date
mskim f28a530e85 DB 동작 검수 필요 작업 완료 2025-12-15 15:21:23 +09:00
mskim 2a74e6a8e7 db 설치 명령어 추가 2025-12-15 15:16:13 +09:00
mskim e1937aea60 실행명령어
podman run -d -p 8000:7997 docker.io/michaelf34/infinity --model-id sentence-transformers/all-mpnet-base-v2

임베딩 모델 추가 완료
2025-12-15 14:12:30 +09:00
mskim d01de88078 테스트용 임베딩 모델 추가 2025-12-15 09:57:37 +09:00
10 changed files with 537 additions and 9 deletions

View File

@ -0,0 +1,23 @@
dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
dnf update -y
dnf -qy module disable postgresql
dnf install -y postgresql17-server
su - postgres
/usr/pgsql-17/bin/initdb -D /var/lib/pgsql/17/data/
exit
systemctl enable --now postgresql-17
cd /tmp
git clone --branch v0.8.1 https://github.com/pgvector/pgvector.git
cd pgvector
dnf config-manager --set-enabled crb
dnf install -y perl-IPC-Run
dnf install -y git gcc make postgresql17-devel redhat-rpm-config
PATH=/usr/pgsql-17/bin:$PATH make
PATH=/usr/pgsql-17/bin:$PATH make install
systemctl restart postgresql-17

102
database_setup.sql Normal file
View File

@ -0,0 +1,102 @@
-- ============================================
-- Dewey 애플리케이션 데이터베이스 설정 스크립트
-- ============================================
-- 이 스크립트는 postgres superuser로 실행해야 합니다.
-- 실행 방법: psql -U postgres -f database_setup.sql
-- 1. dewey 사용자 생성 (이미 존재하면 스킵)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_user WHERE usename = 'dewey') THEN
CREATE USER dewey WITH PASSWORD '0bk1rWu98mGl5ea3';
RAISE NOTICE 'dewey 사용자가 생성되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자가 이미 존재합니다.';
END IF;
END $$;
-- 2. dewey_memory 데이터베이스 생성 (이미 존재하면 스킵)
DO $$
BEGIN
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'dewey_memory') THEN
CREATE DATABASE dewey_memory OWNER dewey;
RAISE NOTICE 'dewey_memory 데이터베이스가 생성되었습니다.';
ELSE
RAISE NOTICE 'dewey_memory 데이터베이스가 이미 존재합니다.';
END IF;
END $$;
-- 3. dewey_memory 데이터베이스에 연결
\c dewey_memory
-- 3. pgvector 확장 설치 (superuser 권한 필요)
CREATE EXTENSION IF NOT EXISTS vector;
-- 4. dewey 사용자에게 필요한 최소 권한만 부여
-- (보안을 위해 최소한의 권한만 부여)
GRANT CONNECT ON DATABASE dewey_memory TO dewey;
GRANT USAGE ON SCHEMA public TO dewey;
GRANT CREATE ON SCHEMA public TO dewey;
-- 5. 기존 테이블/시퀀스에 대한 권한 부여
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO dewey;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO dewey;
-- 6. 향후 생성될 테이블/시퀀스에 대한 기본 권한 설정
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO dewey;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO dewey;
-- 7. dewey 사용자가 확장을 생성할 수 없도록 명시적으로 제한
-- (확장 생성은 superuser만 가능하므로 이미 제한되어 있음)
-- 하지만 혹시 모를 권한을 명시적으로 제거
REVOKE CREATE ON DATABASE dewey_memory FROM dewey;
-- 8. dewey 사용자가 다른 사용자를 생성하거나 권한을 부여할 수 없도록 제한
-- (이미 dewey는 일반 사용자이므로 불필요하지만 명시적으로 확인)
DO $$
BEGIN
-- dewey가 superuser인지 확인하고 제거 (혹시 모를 경우 대비)
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usesuper = true
) THEN
ALTER USER dewey WITH NOSUPERUSER;
RAISE NOTICE 'dewey 사용자의 superuser 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 일반 사용자입니다.';
END IF;
-- dewey가 데이터베이스를 생성할 수 없도록 제한
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usecreatedb = true
) THEN
ALTER USER dewey WITH NOCREATEDB;
RAISE NOTICE 'dewey 사용자의 CREATEDB 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 CREATEDB 권한이 없습니다.';
END IF;
-- dewey가 다른 사용자를 생성할 수 없도록 제한
IF EXISTS (
SELECT 1 FROM pg_user WHERE usename = 'dewey' AND usecreaterole = true
) THEN
ALTER USER dewey WITH NOCREATEROLE;
RAISE NOTICE 'dewey 사용자의 CREATEROLE 권한이 제거되었습니다.';
ELSE
RAISE NOTICE 'dewey 사용자는 이미 CREATEROLE 권한이 없습니다.';
END IF;
END $$;
-- 9. 권한 확인
\echo '=== dewey 사용자 권한 확인 ==='
\du dewey
\echo '=== public 스키마 권한 확인 ==='
\dn+ public
\echo '=== pgvector 확장 확인 ==='
SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';
\echo '=== 스크립트 실행 완료 ==='
\echo 'dewey 사용자는 이제 최소한의 권한만 가지고 있습니다.'
\echo '애플리케이션은 테이블/인덱스 생성 및 데이터 조작만 가능합니다.'

View File

@ -0,0 +1,162 @@
package com.pandol365.dewey.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* 데이터베이스 초기화
* 프로젝트 시작 pgvector 확장 테이블 자동 생성
*/
@Slf4j
@Component
@Order(1) // 다른 초기화보다 먼저 실행
@RequiredArgsConstructor
public class DatabaseInitializer implements CommandLineRunner {
private final JdbcTemplate jdbcTemplate;
@Override
public void run(String... args) {
try {
log.info("=== 데이터베이스 초기화 시작 ===");
// 1. pgvector 확장 확인 (확장 생성은 시도만 하고, 실패해도 계속 진행)
boolean pgvectorAvailable = checkPgVectorExtension();
if (!pgvectorAvailable) {
log.warn("pgvector 확장이 설치되지 않았습니다. 벡터 검색 기능을 사용하려면 pgvector 확장을 설치해주세요.");
log.warn("설치 방법: PostgreSQL 서버에서 다음 명령어 실행:");
log.warn(" psql -U postgres -d dewey_memory -c 'CREATE EXTENSION vector;'");
log.warn("또는 dewey 사용자에게 확장 생성 권한 부여 후 애플리케이션 재시작");
log.info("=== 데이터베이스 초기화 완료 (pgvector 없음) ===");
return;
}
// 2. memory_embeddings 테이블 생성
createMemoryEmbeddingsTable();
// 3. 인덱스 생성
createVectorIndex();
log.info("=== 데이터베이스 초기화 완료 ===");
} catch (Exception e) {
log.error("데이터베이스 초기화 실패: {}", e.getMessage(), e);
// 초기화 실패해도 애플리케이션은 계속 실행 (이미 존재하는 경우 )
}
}
/**
* pgvector 확장 확인
* @return 확장이 사용 가능하면 true
*/
private boolean checkPgVectorExtension() {
try {
log.info("pgvector 확장 확인 중...");
// 현재 데이터베이스에서 확장이 이미 존재하는지 확인
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장이 이미 설치되어 있습니다.");
return true;
}
// 확장이 없으면 생성 시도
log.info("pgvector 확장 생성 시도 중...");
try {
jdbcTemplate.execute("CREATE EXTENSION IF NOT EXISTS vector");
// 생성 다시 확인
count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장 생성 완료");
return true;
} else {
log.warn("pgvector 확장 생성 후에도 확인되지 않습니다.");
return false;
}
} catch (Exception e) {
// 확장이 이미 존재하는 경우도 있으므로 다시 확인
log.debug("CREATE EXTENSION 실패, 재확인 중: {}", e.getMessage());
count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM pg_extension WHERE extname = 'vector'",
Integer.class
);
if (count != null && count > 0) {
log.info("pgvector 확장이 존재합니다 (생성 시도 중 오류 발생했지만 확장은 존재함)");
return true;
}
log.warn("pgvector 확장 생성 실패: {}", e.getMessage());
log.warn("현재 데이터베이스에 pgvector 확장이 없습니다. 다음 명령어를 실행해주세요:");
log.warn(" psql -d dewey_memory -c 'CREATE EXTENSION vector;'");
return false;
}
} catch (Exception e) {
log.error("pgvector 확장 확인 중 오류 발생: {}", e.getMessage(), e);
return false;
}
}
/**
* memory_embeddings 테이블 생성
*/
private void createMemoryEmbeddingsTable() {
try {
log.info("memory_embeddings 테이블 생성 중...");
String sql = """
CREATE TABLE IF NOT EXISTS memory_embeddings (
id BIGSERIAL PRIMARY KEY,
memory_id BIGINT NULL,
user_id VARCHAR(255),
memory_text TEXT NOT NULL,
importance INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
embedding VECTOR(768) NOT NULL
)
""";
jdbcTemplate.execute(sql);
log.info("memory_embeddings 테이블 생성 완료");
} catch (Exception e) {
log.warn("memory_embeddings 테이블 생성 실패 (이미 존재할 수 있음): {}", e.getMessage());
}
}
/**
* 벡터 인덱스 생성 (cosine similarity)
*/
private void createVectorIndex() {
try {
log.info("벡터 인덱스 생성 중...");
// 기존 인덱스가 있으면 삭제 재생성 (IF NOT EXISTS는 ivfflat에서 지원 )
String dropIndexSql = "DROP INDEX IF EXISTS memory_embeddings_embedding_idx";
jdbcTemplate.execute(dropIndexSql);
// ivfflat 인덱스 생성 (lists=100은 데이터량에 따라 조정 필요)
String createIndexSql = """
CREATE INDEX memory_embeddings_embedding_idx
ON memory_embeddings USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100)
""";
jdbcTemplate.execute(createIndexSql);
log.info("벡터 인덱스 생성 완료");
} catch (Exception e) {
log.warn("벡터 인덱스 생성 실패 (이미 존재할 수 있음): {}", e.getMessage());
}
}
}

View File

@ -0,0 +1,16 @@
package com.pandol365.dewey.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class EmbeddingConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder.build();
}
}

View File

@ -0,0 +1,128 @@
package com.pandol365.dewey.domain.memory.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.List;
import java.util.Map;
/**
* 임베딩 생성 클라이언트
* all-mpnet-base-v2 (768d) 같은 로컬/외부 서비스에 HTTP 호출
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmbeddingClient {
private final RestTemplate restTemplate;
@Value("${embedding.api.base-url:http://localhost:8000}")
private String embeddingBaseUrl;
@Value("${embedding.api.path:/embeddings}")
private String embeddingPath;
@Value("${embedding.api.model:sentence-transformers/all-mpnet-base-v2}")
private String embeddingModel;
/**
* 텍스트 임베딩 생성
* Infinity API 형식: 요청 { "model": "...", "input": "..." }, 응답 { "data": [{"embedding": [...]}] }
*/
public float[] embed(String text) {
try {
String url = UriComponentsBuilder.newInstance()
.scheme(extractScheme(embeddingBaseUrl))
.host(extractHost(embeddingBaseUrl))
.port(extractPort(embeddingBaseUrl))
.path(embeddingPath == null ? "/embeddings" : embeddingPath)
.toUriString();
// Infinity API 요청 형식: {"model": "...", "input": "..."}
Map<String, Object> request = Map.of(
"model", embeddingModel != null ? embeddingModel : "sentence-transformers/all-mpnet-base-v2",
"input", text
);
@SuppressWarnings("unchecked")
Map<String, Object> resp = restTemplate.postForObject(
url,
request,
Map.class
);
if (resp == null || !resp.containsKey("data")) {
throw new IllegalStateException("data field missing in response");
}
Object dataObj = resp.get("data");
if (!(dataObj instanceof List<?> dataList) || dataList.isEmpty()) {
throw new IllegalStateException("data is not a non-empty list");
}
// data[0].embedding 추출
Object firstItem = dataList.get(0);
if (!(firstItem instanceof Map<?, ?> firstMap)) {
throw new IllegalStateException("data[0] is not a map");
}
Object embObj = firstMap.get("embedding");
if (!(embObj instanceof List<?> list)) {
throw new IllegalStateException("embedding is not a list");
}
float[] embedding = new float[list.size()];
for (int i = 0; i < list.size(); i++) {
Object v = list.get(i);
if (v instanceof Number num) {
embedding[i] = num.floatValue();
} else {
throw new IllegalStateException("embedding element is not numeric");
}
}
return embedding;
} catch (Exception e) {
log.error("임베딩 생성 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to generate embedding", e);
}
}
// 간단한 URL 파서 (기본값 포함)
private String extractScheme(String url) {
if (url == null || url.isBlank()) return "http";
int idx = url.indexOf("://");
return idx > 0 ? url.substring(0, idx) : "http";
}
private String extractHost(String url) {
if (url == null || url.isBlank()) return "localhost";
String noScheme = url.contains("://") ? url.substring(url.indexOf("://") + 3) : url;
int slash = noScheme.indexOf('/');
String hostPort = slash >= 0 ? noScheme.substring(0, slash) : noScheme;
int colon = hostPort.indexOf(':');
return colon >= 0 ? hostPort.substring(0, colon) : hostPort;
}
private Integer extractPort(String url) {
if (url == null || url.isBlank()) return null;
String noScheme = url.contains("://") ? url.substring(url.indexOf("://") + 3) : url;
int slash = noScheme.indexOf('/');
String hostPort = slash >= 0 ? noScheme.substring(0, slash) : noScheme;
int colon = hostPort.indexOf(':');
if (colon >= 0) {
String portStr = hostPort.substring(colon + 1);
try {
return Integer.parseInt(portStr);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View File

@ -0,0 +1,73 @@
package com.pandol365.dewey.domain.memory.service;
import com.pandol365.dewey.domain.memory.model.Memory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import com.pgvector.PGvector;
import java.time.LocalDateTime;
import java.util.List;
/**
* PGVector 기반 메모리 벡터 스토어
* 별도 테이블 memory_embeddings 저장/검색
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MemoryVectorStore {
private final JdbcTemplate jdbcTemplate;
private static final String TABLE_NAME = "memory_embeddings";
/**
* 임베딩과 함께 저장 (permanent 목적)
*/
public void save(String userId, String memoryText, Integer importance, float[] embedding) {
try {
PGvector pgVector = new PGvector(embedding);
jdbcTemplate.update(
"INSERT INTO " + TABLE_NAME + " (user_id, memory_text, importance, created_at, embedding) VALUES (?, ?, ?, ?, ?)",
userId,
memoryText,
importance != null ? importance : 1,
LocalDateTime.now(),
pgVector
);
} catch (Exception e) {
log.error("memory_embeddings 저장 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to store embedding", e);
}
}
/**
* 코사인 유사도 기반 검색
*/
public List<Memory> search(float[] queryEmbedding, int limit) {
try {
PGvector pgVector = new PGvector(queryEmbedding);
String sql = "SELECT id, user_id, memory_text, importance, created_at, " +
" (embedding <=> ?) AS distance " +
"FROM " + TABLE_NAME + " " +
"ORDER BY embedding <=> ? " +
"LIMIT ?";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Memory m = new Memory();
m.setId(rs.getLong("id"));
m.setUserId(rs.getString("user_id"));
m.setMemoryText(rs.getString("memory_text"));
m.setImportance(rs.getInt("importance"));
m.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
m.setUpdatedAt(null);
return m;
}, pgVector, pgVector, limit);
} catch (Exception e) {
log.error("memory_embeddings 검색 실패: {}", e.getMessage(), e);
throw new RuntimeException("Failed to search embeddings", e);
}
}
}

View File

@ -4,6 +4,8 @@ import com.pandol365.dewey.domain.memory.model.Memory;
import com.pandol365.dewey.domain.memory.model.TemporaryMemory; import com.pandol365.dewey.domain.memory.model.TemporaryMemory;
import com.pandol365.dewey.domain.memory.repository.MemoryRepository; import com.pandol365.dewey.domain.memory.repository.MemoryRepository;
import com.pandol365.dewey.domain.memory.service.MemoryService; import com.pandol365.dewey.domain.memory.service.MemoryService;
import com.pandol365.dewey.domain.memory.service.EmbeddingClient;
import com.pandol365.dewey.domain.memory.service.MemoryVectorStore;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@ -29,6 +31,8 @@ public class MemoryServiceImpl implements MemoryService {
private final MemoryRepository memoryRepository; private final MemoryRepository memoryRepository;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final EmbeddingClient embeddingClient;
private final MemoryVectorStore memoryVectorStore;
private static final String TEMP_MEMORY_KEY_PREFIX = "tempMemory:"; private static final String TEMP_MEMORY_KEY_PREFIX = "tempMemory:";
private static final String USER_TEMP_MEMORY_KEY_PREFIX = "user:tempMemories:"; private static final String USER_TEMP_MEMORY_KEY_PREFIX = "user:tempMemories:";
@ -59,6 +63,14 @@ public class MemoryServiceImpl implements MemoryService {
redisTemplate.opsForZSet().add(userMemoriesKey, memoryKey, System.currentTimeMillis()); redisTemplate.opsForZSet().add(userMemoriesKey, memoryKey, System.currentTimeMillis());
redisTemplate.expire(userMemoriesKey, ttl); redisTemplate.expire(userMemoriesKey, ttl);
// 임베딩 생성 PGVector 저장 (실패해도 Redis 저장은 유지)
try {
float[] embedding = embeddingClient.embed(memoryText);
memoryVectorStore.save(userId, memoryText, importance, embedding);
} catch (Exception e) {
log.warn("임베딩 저장 실패 (계속 진행): {}", e.getMessage());
}
return temporaryMemory; return temporaryMemory;
} }
@ -109,11 +121,17 @@ public class MemoryServiceImpl implements MemoryService {
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<Memory> searchMemoriesByVector(String query, String userId, Integer limit) { public List<Memory> searchMemoriesByVector(String query, String userId, Integer limit) {
log.info("벡터 기반 메모리 검색: query={}, userId={}, limit={}", query, userId, limit); log.info("벡터 기반 메모리 검색: query={}, userId(optional)={}, limit={}", query, userId, limit);
// TODO: 벡터 유사도 검색 구현 (Spring AI PgVectorStore 사용) int topK = limit != null && limit > 0 ? limit : 5;
try {
float[] embedding = embeddingClient.embed(query);
return memoryVectorStore.search(embedding, topK);
} catch (Exception e) {
log.error("벡터 검색 실패: {}", e.getMessage(), e);
return List.of(); return List.of();
} }
}
@Override @Override
@Transactional(readOnly = true) @Transactional(readOnly = true)

View File

@ -1,7 +1,7 @@
spring.application.name=dewey spring.application.name=dewey
# PostgreSQL # PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/dewey_memory spring.datasource.url=jdbc:postgresql://192.168.100.230:5432/dewey_memory
spring.datasource.username=dewey spring.datasource.username=dewey
spring.datasource.password=0bk1rWu98mGl5ea3 spring.datasource.password=0bk1rWu98mGl5ea3
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
@ -15,3 +15,9 @@ spring.jpa.properties.hibernate.format_sql=true
# Redis # Redis
spring.data.redis.host=localhost spring.data.redis.host=localhost
spring.data.redis.port=6379 spring.data.redis.port=6379
# Embedding service
# all-mpnet-base-v2 768d embedding endpoint (infinity)
embedding.api.base-url=http://192.168.100.220:8000
embedding.api.path=/embeddings
embedding.api.model=sentence-transformers/all-mpnet-base-v2