perf: 收敛后端资源与健康检查开销
- 缩小模块扫描范围并显式注册各业务模块自动配置 - 增加可配置线程池、MQ 连接池与消费线程池,降低默认资源占用 - 将 RAG 与分析库中间件探活下沉到健康检查并增加短缓存 - 补齐文档向量库生命周期释放与 SSE 断连清理
This commit is contained in:
@@ -115,6 +115,11 @@
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>easyflow-common-mq</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-actuator</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.easyagents</groupId>
|
||||
|
||||
@@ -2,8 +2,16 @@ package tech.easyflow.ai.config;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import tech.easyflow.ai.documentimport.task.DocumentImportParseMonitorProperties;
|
||||
|
||||
@MapperScan("tech.easyflow.ai.mapper")
|
||||
@ComponentScan("tech.easyflow.ai")
|
||||
@EnableConfigurationProperties({
|
||||
DocumentImportParseMonitorProperties.class,
|
||||
RagHealthProperties.class
|
||||
})
|
||||
@AutoConfiguration
|
||||
public class AiModuleConfig {
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 健康检查短缓存支持。
|
||||
*/
|
||||
public abstract class CachedHealthIndicatorSupport {
|
||||
|
||||
private final RagHealthProperties properties;
|
||||
private final Clock clock;
|
||||
private volatile CacheEntry cacheEntry;
|
||||
|
||||
/**
|
||||
* 创建健康检查缓存支持。
|
||||
*
|
||||
* @param properties RAG 健康检查配置
|
||||
*/
|
||||
protected CachedHealthIndicatorSupport(RagHealthProperties properties) {
|
||||
this(properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建健康检查缓存支持。
|
||||
*
|
||||
* @param properties RAG 健康检查配置
|
||||
* @param clock 时钟
|
||||
*/
|
||||
protected CachedHealthIndicatorSupport(RagHealthProperties properties, Clock clock) {
|
||||
this.properties = properties;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行带短缓存的健康检查。
|
||||
*
|
||||
* @return 健康状态
|
||||
*/
|
||||
protected Health cachedHealth() {
|
||||
long now = clock.millis();
|
||||
CacheEntry current = cacheEntry;
|
||||
if (current != null && current.expireAtMillis > now) {
|
||||
return current.health;
|
||||
}
|
||||
synchronized (this) {
|
||||
current = cacheEntry;
|
||||
if (current != null && current.expireAtMillis > now) {
|
||||
return current.health;
|
||||
}
|
||||
Health health = doHealthCheck();
|
||||
cacheEntry = new CacheEntry(health, now + cacheTtlMillis());
|
||||
return health;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行实际健康检查。
|
||||
*
|
||||
* @return 健康状态
|
||||
*/
|
||||
protected abstract Health doHealthCheck();
|
||||
|
||||
private long cacheTtlMillis() {
|
||||
Duration cacheTtl = properties.getCacheTtl();
|
||||
if (cacheTtl == null || cacheTtl.isZero() || cacheTtl.isNegative()) {
|
||||
return 0L;
|
||||
}
|
||||
return cacheTtl.toMillis();
|
||||
}
|
||||
|
||||
private static class CacheEntry {
|
||||
|
||||
private final Health health;
|
||||
private final long expireAtMillis;
|
||||
|
||||
private CacheEntry(Health health, long expireAtMillis) {
|
||||
this.health = health;
|
||||
this.expireAtMillis = expireAtMillis;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* EasyFlow 业务线程池配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.thread-pool")
|
||||
public class EasyFlowThreadPoolProperties {
|
||||
|
||||
private Pool sse = new Pool(4, 16, 2000, 30, true);
|
||||
private Pool documentImport = new Pool(2, 4, 200, 60, true);
|
||||
|
||||
/**
|
||||
* 获取 SSE 线程池配置。
|
||||
*
|
||||
* @return SSE 线程池配置
|
||||
*/
|
||||
public Pool getSse() {
|
||||
return sse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 SSE 线程池配置。
|
||||
*
|
||||
* @param sse SSE 线程池配置
|
||||
*/
|
||||
public void setSse(Pool sse) {
|
||||
this.sse = sse;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档导入线程池配置。
|
||||
*
|
||||
* @return 文档导入线程池配置
|
||||
*/
|
||||
public Pool getDocumentImport() {
|
||||
return documentImport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文档导入线程池配置。
|
||||
*
|
||||
* @param documentImport 文档导入线程池配置
|
||||
*/
|
||||
public void setDocumentImport(Pool documentImport) {
|
||||
this.documentImport = documentImport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 线程池配置项。
|
||||
*/
|
||||
public static class Pool {
|
||||
|
||||
private int coreSize;
|
||||
private int maxSize;
|
||||
private int queueCapacity;
|
||||
private int keepAliveSeconds;
|
||||
private boolean allowCoreThreadTimeout;
|
||||
|
||||
/**
|
||||
* 创建默认线程池配置。
|
||||
*/
|
||||
public Pool() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建线程池配置。
|
||||
*
|
||||
* @param coreSize 核心线程数
|
||||
* @param maxSize 最大线程数
|
||||
* @param queueCapacity 队列容量
|
||||
* @param keepAliveSeconds 空闲线程存活时间
|
||||
* @param allowCoreThreadTimeout 是否允许核心线程超时
|
||||
*/
|
||||
public Pool(int coreSize, int maxSize, int queueCapacity, int keepAliveSeconds, boolean allowCoreThreadTimeout) {
|
||||
this.coreSize = coreSize;
|
||||
this.maxSize = maxSize;
|
||||
this.queueCapacity = queueCapacity;
|
||||
this.keepAliveSeconds = keepAliveSeconds;
|
||||
this.allowCoreThreadTimeout = allowCoreThreadTimeout;
|
||||
}
|
||||
|
||||
public int getCoreSize() {
|
||||
return coreSize;
|
||||
}
|
||||
|
||||
public void setCoreSize(int coreSize) {
|
||||
this.coreSize = coreSize;
|
||||
}
|
||||
|
||||
public int getMaxSize() {
|
||||
return maxSize;
|
||||
}
|
||||
|
||||
public void setMaxSize(int maxSize) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
public int getQueueCapacity() {
|
||||
return queueCapacity;
|
||||
}
|
||||
|
||||
public void setQueueCapacity(int queueCapacity) {
|
||||
this.queueCapacity = queueCapacity;
|
||||
}
|
||||
|
||||
public int getKeepAliveSeconds() {
|
||||
return keepAliveSeconds;
|
||||
}
|
||||
|
||||
public void setKeepAliveSeconds(int keepAliveSeconds) {
|
||||
this.keepAliveSeconds = keepAliveSeconds;
|
||||
}
|
||||
|
||||
public boolean isAllowCoreThreadTimeout() {
|
||||
return allowCoreThreadTimeout;
|
||||
}
|
||||
|
||||
public void setAllowCoreThreadTimeout(boolean allowCoreThreadTimeout) {
|
||||
this.allowCoreThreadTimeout = allowCoreThreadTimeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import com.easyagents.engine.es.ElasticSearcher;
|
||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||
import com.easyagents.store.milvus.MilvusVectorStore;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.rag.KeywordEngineType;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* RAG 依赖中间件健康检查。
|
||||
*/
|
||||
public class RagHealthIndicator {
|
||||
|
||||
/**
|
||||
* Milvus 健康检查。
|
||||
*/
|
||||
@Component("ragMilvusHealthIndicator")
|
||||
public static class RagMilvusHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
||||
|
||||
private final AiMilvusConfig aiMilvusConfig;
|
||||
|
||||
/**
|
||||
* 创建 Milvus 健康检查器。
|
||||
*
|
||||
* @param aiMilvusConfig Milvus 配置
|
||||
* @param healthProperties RAG 健康检查配置
|
||||
*/
|
||||
public RagMilvusHealthIndicator(AiMilvusConfig aiMilvusConfig, RagHealthProperties healthProperties) {
|
||||
super(healthProperties);
|
||||
this.aiMilvusConfig = aiMilvusConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Milvus 是否可连接。
|
||||
*
|
||||
* @return 健康状态
|
||||
*/
|
||||
@Override
|
||||
public Health health() {
|
||||
return cachedHealth();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Health doHealthCheck() {
|
||||
MilvusVectorStore vectorStore = null;
|
||||
try {
|
||||
vectorStore = new MilvusVectorStore(
|
||||
aiMilvusConfig.copyForCollection("__rag_health_probe__")
|
||||
);
|
||||
if (vectorStore.checkAvailable()) {
|
||||
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||
}
|
||||
return Health.down().withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||
} catch (Exception e) {
|
||||
return Health.down(e).withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(vectorStore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键词检索健康检查。
|
||||
*/
|
||||
@Component("ragKeywordSearchHealthIndicator")
|
||||
public static class RagKeywordSearchHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
||||
|
||||
private final SearcherFactory searcherFactory;
|
||||
private final AiLuceneConfig aiLuceneConfig;
|
||||
|
||||
/**
|
||||
* 创建关键词检索健康检查器。
|
||||
*
|
||||
* @param searcherFactory 检索器工厂
|
||||
* @param aiLuceneConfig Lucene 配置
|
||||
* @param healthProperties RAG 健康检查配置
|
||||
*/
|
||||
public RagKeywordSearchHealthIndicator(SearcherFactory searcherFactory,
|
||||
AiLuceneConfig aiLuceneConfig,
|
||||
RagHealthProperties healthProperties) {
|
||||
super(healthProperties);
|
||||
this.searcherFactory = searcherFactory;
|
||||
this.aiLuceneConfig = aiLuceneConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前关键词检索引擎是否可用。
|
||||
*
|
||||
* @return 健康状态
|
||||
*/
|
||||
@Override
|
||||
public Health health() {
|
||||
return cachedHealth();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Health doHealthCheck() {
|
||||
KeywordEngineType engineType = KeywordEngineType.from(SpringContextUtil.getProperty("rag.engine", "ES"));
|
||||
if (engineType == KeywordEngineType.LUCENE) {
|
||||
return checkLuceneDirectory(engineType);
|
||||
}
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (searcher instanceof ElasticSearcher elasticSearcher && elasticSearcher.checkAvailable()) {
|
||||
return Health.up().withDetail("engine", engineType.name()).build();
|
||||
}
|
||||
return Health.down().withDetail("engine", engineType.name()).build();
|
||||
}
|
||||
|
||||
private Health checkLuceneDirectory(KeywordEngineType engineType) {
|
||||
String indexDirPath = aiLuceneConfig.getIndexDirPath();
|
||||
if (StringUtil.noText(indexDirPath)) {
|
||||
return Health.down()
|
||||
.withDetail("engine", engineType.name())
|
||||
.withDetail("reason", "Lucene 索引目录未配置")
|
||||
.build();
|
||||
}
|
||||
File indexDir = new File(indexDirPath);
|
||||
if (indexDir.exists() && indexDir.isDirectory() && indexDir.canRead() && indexDir.canWrite()) {
|
||||
return Health.up()
|
||||
.withDetail("engine", engineType.name())
|
||||
.withDetail("indexDir", indexDirPath)
|
||||
.build();
|
||||
}
|
||||
return Health.down()
|
||||
.withDetail("engine", engineType.name())
|
||||
.withDetail("indexDir", indexDirPath)
|
||||
.withDetail("reason", "Lucene 索引目录不可读写")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* RAG 健康检查配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.ai.rag.health")
|
||||
public class RagHealthProperties {
|
||||
|
||||
private Duration cacheTtl = Duration.ofSeconds(5);
|
||||
|
||||
/**
|
||||
* 获取健康检查结果缓存时间。
|
||||
*
|
||||
* @return 缓存时间
|
||||
*/
|
||||
public Duration getCacheTtl() {
|
||||
return cacheTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置健康检查结果缓存时间。
|
||||
*
|
||||
* @param cacheTtl 缓存时间
|
||||
*/
|
||||
public void setCacheTtl(Duration cacheTtl) {
|
||||
this.cacheTtl = cacheTtl == null || cacheTtl.isNegative() ? Duration.ofSeconds(5) : cacheTtl;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import com.easyagents.engine.es.ElasticSearcher;
|
||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||
import com.easyagents.store.milvus.MilvusVectorStore;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.rag.KeywordEngineType;
|
||||
@@ -16,9 +13,6 @@ import java.io.File;
|
||||
@Component
|
||||
public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
||||
|
||||
private static final int STARTUP_CHECK_RETRY_TIMES = 10;
|
||||
private static final long STARTUP_CHECK_RETRY_INTERVAL_MS = 1000L;
|
||||
|
||||
@Resource
|
||||
private AiMilvusConfig aiMilvusConfig;
|
||||
|
||||
@@ -26,31 +20,21 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
||||
private AiLuceneConfig aiLuceneConfig;
|
||||
|
||||
@Resource
|
||||
private SearcherFactory searcherFactory;
|
||||
private AiEsConfig aiEsConfig;
|
||||
|
||||
/**
|
||||
* 校验 RAG 基础配置。
|
||||
*/
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
validateMilvus();
|
||||
validateMilvusConfig();
|
||||
validateKeywordSearcher();
|
||||
}
|
||||
|
||||
private void validateMilvus() {
|
||||
Exception lastException = null;
|
||||
for (int i = 0; i < STARTUP_CHECK_RETRY_TIMES; i++) {
|
||||
try {
|
||||
MilvusVectorStore vectorStore = new MilvusVectorStore(aiMilvusConfig.copyForCollection("__rag_boot_probe__"));
|
||||
if (vectorStore.checkAvailable()) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
}
|
||||
sleepBeforeRetry();
|
||||
private void validateMilvusConfig() {
|
||||
if (StringUtil.noText(aiMilvusConfig.getUri())) {
|
||||
throw new BusinessException("Milvus uri 未配置,请检查 rag.milvus.uri");
|
||||
}
|
||||
if (lastException != null) {
|
||||
throw new BusinessException("Milvus 服务不可用,项目启动失败,请检查 rag.milvus 配置与服务状态: " + lastException.getMessage());
|
||||
}
|
||||
throw new BusinessException("Milvus 服务不可用,项目启动失败,请检查 rag.milvus 配置与服务状态");
|
||||
}
|
||||
|
||||
private void validateKeywordSearcher() {
|
||||
@@ -61,21 +45,12 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
||||
validateLuceneDirectory();
|
||||
return;
|
||||
}
|
||||
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (!(searcher instanceof ElasticSearcher) || !checkElasticAvailable((ElasticSearcher) searcher)) {
|
||||
throw new BusinessException("ES 服务不可用,项目启动失败,请检查 rag.engine 与 rag.searcher.elastic 配置");
|
||||
if (StringUtil.noText(aiEsConfig.getHost())) {
|
||||
throw new BusinessException("ES 地址未配置,请检查 rag.searcher.elastic.host");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkElasticAvailable(ElasticSearcher elasticSearcher) {
|
||||
for (int i = 0; i < STARTUP_CHECK_RETRY_TIMES; i++) {
|
||||
if (elasticSearcher.checkAvailable()) {
|
||||
return true;
|
||||
}
|
||||
sleepBeforeRetry();
|
||||
if (StringUtil.noText(aiEsConfig.getIndexName())) {
|
||||
throw new BusinessException("ES 索引未配置,请检查 rag.searcher.elastic.indexName");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void validateLuceneDirectory() {
|
||||
@@ -92,12 +67,4 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
||||
}
|
||||
}
|
||||
|
||||
private void sleepBeforeRetry() {
|
||||
try {
|
||||
Thread.sleep(STARTUP_CHECK_RETRY_INTERVAL_MS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("中间件启动校验被中断");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,28 @@ package tech.easyflow.ai.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(EasyFlowThreadPoolProperties.class)
|
||||
public class ThreadPoolConfig {
|
||||
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
|
||||
|
||||
private final EasyFlowThreadPoolProperties properties;
|
||||
|
||||
/**
|
||||
* 创建线程池配置。
|
||||
*
|
||||
* @param properties 线程池配置属性
|
||||
*/
|
||||
public ThreadPoolConfig(EasyFlowThreadPoolProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 SSE 消息发送线程池。
|
||||
*
|
||||
@@ -19,11 +32,12 @@ public class ThreadPoolConfig {
|
||||
@Bean(name = "sseThreadPool")
|
||||
public ThreadPoolTaskExecutor sseThreadPool() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
int cpuCoreNum = Runtime.getRuntime().availableProcessors(); // 获取CPU核心数(4核返回4)
|
||||
executor.setCorePoolSize(cpuCoreNum * 2); // 核心线程数
|
||||
executor.setMaxPoolSize(cpuCoreNum * 10); // 最大线程数(峰值时扩容,避免线程过多导致上下文切换)
|
||||
executor.setQueueCapacity(8000); // 任务队列容量
|
||||
executor.setKeepAliveSeconds(30); // 空闲线程存活时间:30秒(非核心线程空闲后销毁,节省资源)
|
||||
EasyFlowThreadPoolProperties.Pool pool = properties.getSse();
|
||||
executor.setCorePoolSize(pool.getCoreSize());
|
||||
executor.setMaxPoolSize(pool.getMaxSize());
|
||||
executor.setQueueCapacity(pool.getQueueCapacity());
|
||||
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
|
||||
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
|
||||
executor.setThreadNamePrefix("sse-sender-");
|
||||
|
||||
// 拒绝策略
|
||||
@@ -47,11 +61,12 @@ public class ThreadPoolConfig {
|
||||
@Bean(name = "documentImportTaskExecutor")
|
||||
public ThreadPoolTaskExecutor documentImportTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
int cpuCoreNum = Runtime.getRuntime().availableProcessors();
|
||||
executor.setCorePoolSize(Math.max(2, cpuCoreNum));
|
||||
executor.setMaxPoolSize(Math.max(4, cpuCoreNum * 2));
|
||||
executor.setQueueCapacity(200);
|
||||
executor.setKeepAliveSeconds(60);
|
||||
EasyFlowThreadPoolProperties.Pool pool = properties.getDocumentImport();
|
||||
executor.setCorePoolSize(pool.getCoreSize());
|
||||
executor.setMaxPoolSize(pool.getMaxSize());
|
||||
executor.setQueueCapacity(pool.getQueueCapacity());
|
||||
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
|
||||
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
|
||||
executor.setThreadNamePrefix("document-import-");
|
||||
executor.setRejectedExecutionHandler((runnable, executorService) -> {
|
||||
log.error("文档导入线程池过载!核心线程数:{},最大线程数:{},队列任务数:{}",
|
||||
|
||||
@@ -24,8 +24,8 @@ public class DocumentImportParseMonitor {
|
||||
* 定时收敛运行中的桥接解析任务状态。
|
||||
*/
|
||||
@Scheduled(
|
||||
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:3000}",
|
||||
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:5000}"
|
||||
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:10000}",
|
||||
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:10000}"
|
||||
)
|
||||
public void reconcileRunningParseTasks() {
|
||||
appService.monitorRunningParseTasks();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package tech.easyflow.ai.documentimport.task;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 文档解析任务监控配置。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "easyflow.ai.document-import.parse-monitor")
|
||||
public class DocumentImportParseMonitorProperties {
|
||||
|
||||
private int batchSize = 10;
|
||||
|
||||
/**
|
||||
* 获取单次监控批量。
|
||||
*
|
||||
* @return 单次监控批量
|
||||
*/
|
||||
public int getBatchSize() {
|
||||
return batchSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单次监控批量。
|
||||
*
|
||||
* @param batchSize 单次监控批量
|
||||
*/
|
||||
public void setBatchSize(int batchSize) {
|
||||
this.batchSize = batchSize <= 0 ? 10 : batchSize;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
||||
import tech.easyflow.ai.entity.Document;
|
||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -28,6 +32,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
@Service
|
||||
public class DocumentImportTaskStatusStreamService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportTaskStatusStreamService.class);
|
||||
private static final long SSE_TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
|
||||
|
||||
private final Map<String, Set<SseEmitter>> knowledgeEmitters = new ConcurrentHashMap<String, Set<SseEmitter>>();
|
||||
@@ -134,6 +139,9 @@ public class DocumentImportTaskStatusStreamService {
|
||||
|
||||
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
|
||||
sseThreadPool.execute(() -> {
|
||||
if (!isEmitterRegistered(topicKey, emitter)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
emitter.send(
|
||||
SseEmitter.event()
|
||||
@@ -142,14 +150,29 @@ public class DocumentImportTaskStatusStreamService {
|
||||
);
|
||||
} catch (Exception e) {
|
||||
removeEmitter(topicKey, emitter);
|
||||
try {
|
||||
emitter.completeWithError(e);
|
||||
} catch (Exception ignored) {
|
||||
if (isClientDisconnected(e)) {
|
||||
LOG.debug("文档导入状态流客户端已断开: topicKey={}, eventName={}, message={}",
|
||||
topicKey, eventName, e.getMessage());
|
||||
return;
|
||||
}
|
||||
LOG.warn("文档导入状态流推送失败: topicKey={}, eventName={}", topicKey, eventName, e);
|
||||
completeQuietly(emitter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定 SSE 连接是否仍注册在主题下,避免已清理连接继续被异步任务写入。
|
||||
*
|
||||
* @param topicKey 主题键
|
||||
* @param emitter SSE 连接
|
||||
* @return 是否仍处于注册状态
|
||||
*/
|
||||
private boolean isEmitterRegistered(String topicKey, SseEmitter emitter) {
|
||||
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||
return emitters != null && emitters.contains(emitter);
|
||||
}
|
||||
|
||||
private void removeEmitter(String topicKey, SseEmitter emitter) {
|
||||
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||
if (emitters == null) {
|
||||
@@ -161,6 +184,46 @@ public class DocumentImportTaskStatusStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断异常是否由客户端断开 SSE 连接导致。
|
||||
*
|
||||
* @param throwable 异常
|
||||
* @return 是否为客户端断连
|
||||
*/
|
||||
private boolean isClientDisconnected(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current != null) {
|
||||
if (current instanceof AsyncRequestNotUsableException || current instanceof IOException) {
|
||||
return true;
|
||||
}
|
||||
String message = current.getMessage();
|
||||
if (message != null) {
|
||||
String lowerMessage = message.toLowerCase();
|
||||
if (lowerMessage.contains("broken pipe")
|
||||
|| lowerMessage.contains("connection reset")
|
||||
|| lowerMessage.contains("response not usable")
|
||||
|| lowerMessage.contains("client abort")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安静关闭 SSE 连接。
|
||||
*
|
||||
* @param emitter SSE 连接
|
||||
*/
|
||||
private void completeQuietly(SseEmitter emitter) {
|
||||
try {
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
LOG.debug("关闭文档导入状态流失败: message={}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String toTopicKey(BigInteger knowledgeId) {
|
||||
return String.valueOf(knowledgeId);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.DocumentImportTaskService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.util.FileUtil;
|
||||
@@ -92,7 +93,6 @@ import java.util.regex.Pattern;
|
||||
public class KnowledgeDocumentImportTaskAppService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(KnowledgeDocumentImportTaskAppService.class);
|
||||
private static final int PARSE_MONITOR_BATCH_SIZE = 20;
|
||||
private static final int INDEX_BATCH_SIZE = 20;
|
||||
private static final String SOURCE_RANGES_KEY = "sourceRanges";
|
||||
private static final String KNOWLEDGE_PARSE_IMAGE_CATEGORY = "knowledge-parse";
|
||||
@@ -122,6 +122,9 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
@Resource
|
||||
private DocumentImportTaskService documentImportTaskService;
|
||||
|
||||
@Resource
|
||||
private DocumentImportParseMonitorProperties parseMonitorProperties;
|
||||
|
||||
@Resource
|
||||
private DocumentImportPreviewService documentImportPreviewService;
|
||||
|
||||
@@ -403,7 +406,7 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
.eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
|
||||
.eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
|
||||
.orderBy(DocumentImportTask::getModified, true)
|
||||
.limit(PARSE_MONITOR_BATCH_SIZE);
|
||||
.limit(parseMonitorProperties.getBatchSize());
|
||||
List<DocumentImportTask> runningTasks = documentImportTaskService.list(queryWrapper);
|
||||
if (runningTasks == null || runningTasks.isEmpty()) {
|
||||
return;
|
||||
@@ -516,6 +519,8 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
rollbackStoredChunks(taskId, document.getId(), storeContext, storedChunks);
|
||||
}
|
||||
markIndexFailed(task, document, truncateError(e.getMessage()));
|
||||
} finally {
|
||||
closeStoreContext(storeContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2123,26 +2128,31 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
if (documentStore == null) {
|
||||
throw new BusinessException("向量数据库配置错误");
|
||||
}
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
try {
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
return new StoreExecutionContext(
|
||||
knowledge,
|
||||
embeddingModel,
|
||||
documentStore,
|
||||
options,
|
||||
searcherFactory.getSearcher()
|
||||
);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
return new StoreExecutionContext(
|
||||
knowledge,
|
||||
embeddingModel,
|
||||
documentStore,
|
||||
options,
|
||||
searcherFactory.getSearcher()
|
||||
);
|
||||
} catch (RuntimeException e) {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
||||
@@ -2221,6 +2231,13 @@ public class KnowledgeDocumentImportTaskAppService {
|
||||
}
|
||||
}
|
||||
|
||||
private void closeStoreContext(StoreExecutionContext storeContext) {
|
||||
if (storeContext == null) {
|
||||
return;
|
||||
}
|
||||
DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
|
||||
}
|
||||
|
||||
private void clearPersistedChunks(BigInteger documentId) {
|
||||
if (documentId == null) {
|
||||
return;
|
||||
|
||||
@@ -31,6 +31,7 @@ import tech.easyflow.ai.mapper.FaqItemMapper;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.ai.utils.CustomBeanUtils;
|
||||
import tech.easyflow.ai.utils.RegexUtils;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
@@ -283,34 +284,38 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
||||
throw new BusinessException("知识库没有配置向量库");
|
||||
}
|
||||
|
||||
Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("知识库没有配置向量模型");
|
||||
}
|
||||
try {
|
||||
Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("知识库没有配置向量模型");
|
||||
}
|
||||
|
||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
||||
SearchWrapper wrapper = new SearchWrapper();
|
||||
wrapper.setMaxResults(docRecallMaxNum);
|
||||
if (minSimilarity != null) {
|
||||
wrapper.setMinScore((double) minSimilarity);
|
||||
}
|
||||
wrapper.setText(keyword);
|
||||
documentStore.setEmbeddingModel(model.toEmbeddingModel());
|
||||
SearchWrapper wrapper = new SearchWrapper();
|
||||
wrapper.setMaxResults(docRecallMaxNum);
|
||||
if (minSimilarity != null) {
|
||||
wrapper.setMinScore((double) minSimilarity);
|
||||
}
|
||||
wrapper.setText(keyword);
|
||||
|
||||
StoreOptions options = StoreOptions.ofCollectionName(documentCollection.getVectorStoreCollection());
|
||||
options.setIndexName(documentCollection.getVectorStoreCollection());
|
||||
List<Document> documents = documentStore.search(wrapper, options);
|
||||
List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
|
||||
LOG.info(
|
||||
"Knowledge vector search completed, knowledgeId={}, collectionName={}, query={}, limit={}, minSimilarity={}, hitCount={}, hits={}",
|
||||
documentCollection.getId(),
|
||||
documentCollection.getVectorStoreCollection(),
|
||||
keyword,
|
||||
docRecallMaxNum,
|
||||
minSimilarity,
|
||||
result.size(),
|
||||
summarizeDocuments(result)
|
||||
);
|
||||
return result;
|
||||
StoreOptions options = StoreOptions.ofCollectionName(documentCollection.getVectorStoreCollection());
|
||||
options.setIndexName(documentCollection.getVectorStoreCollection());
|
||||
List<Document> documents = documentStore.search(wrapper, options);
|
||||
List<Document> result = documents == null ? Collections.<Document>emptyList() : documents;
|
||||
LOG.info(
|
||||
"Knowledge vector search completed, knowledgeId={}, collectionName={}, query={}, limit={}, minSimilarity={}, hitCount={}, hits={}",
|
||||
documentCollection.getId(),
|
||||
documentCollection.getVectorStoreCollection(),
|
||||
keyword,
|
||||
docRecallMaxNum,
|
||||
minSimilarity,
|
||||
result.size(),
|
||||
summarizeDocuments(result)
|
||||
);
|
||||
return result;
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Document> searchKeywordDocuments(DocumentCollection documentCollection, String keyword, int docRecallMaxNum) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import tech.easyflow.ai.service.DocumentChunkService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.DocumentService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.common.ai.rag.ExcelDocumentSplitter;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
@@ -154,34 +155,38 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
return false;
|
||||
}
|
||||
|
||||
Model model = modelService.getById(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return false;
|
||||
try {
|
||||
Model model = modelService.getById(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
return false;
|
||||
}
|
||||
// 设置向量模型
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setCollectionName(knowledge.getVectorStoreCollection());
|
||||
// 查询文本分割表tb_document_chunk中对应的有哪些数据,找出来删除
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.select(DOCUMENT_CHUNK.ID).eq(DocumentChunk::getDocumentId, id);
|
||||
List<BigInteger> chunkIds = documentChunkMapper.selectListByQueryAs(queryWrapper, BigInteger.class);
|
||||
documentStore.delete(chunkIds, options);
|
||||
// 删除搜索引擎中的数据
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (searcher != null) {
|
||||
chunkIds.forEach(searcher::deleteDocument);
|
||||
}
|
||||
int ck = documentChunkMapper.deleteByQuery(QueryWrapper.create().eq(DocumentChunk::getDocumentId, id));
|
||||
if (ck < 0) {
|
||||
return false;
|
||||
}
|
||||
// 再删除指定路径下的文件
|
||||
Document document = documentMapper.selectOneByQuery(queryWrapperDocument);
|
||||
storageService.delete(document.getDocumentPath());
|
||||
return true;
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
// 设置向量模型
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setCollectionName(knowledge.getVectorStoreCollection());
|
||||
// 查询文本分割表tb_document_chunk中对应的有哪些数据,找出来删除
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.select(DOCUMENT_CHUNK.ID).eq(DocumentChunk::getDocumentId, id);
|
||||
List<BigInteger> chunkIds = documentChunkMapper.selectListByQueryAs(queryWrapper, BigInteger.class);
|
||||
documentStore.delete(chunkIds, options);
|
||||
// 删除搜索引擎中的数据
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (searcher != null) {
|
||||
chunkIds.forEach(searcher::deleteDocument);
|
||||
}
|
||||
int ck = documentChunkMapper.deleteByQuery(QueryWrapper.create().eq(DocumentChunk::getDocumentId, id));
|
||||
if (ck < 0) {
|
||||
return false;
|
||||
}
|
||||
// 再删除指定路径下的文件
|
||||
Document document = documentMapper.selectOneByQuery(queryWrapperDocument);
|
||||
storageService.delete(document.getDocumentPath());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -286,8 +291,8 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
}
|
||||
|
||||
StoreExecutionContext storeContext = prepareStoreContext(document);
|
||||
storeDocumentChunks(storeContext, validChunks);
|
||||
try {
|
||||
storeDocumentChunks(storeContext, validChunks);
|
||||
persistDocumentWithChunks(document, validChunks);
|
||||
updateKnowledgeAfterStore(storeContext);
|
||||
return Result.ok();
|
||||
@@ -296,14 +301,20 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
rollbackStoredChunks(storeContext, validChunks);
|
||||
Log.error("保存文档失败: documentId={}, title={}", document.getId(), document.getTitle(), e);
|
||||
throw new BusinessException("保存失败:" + e.getMessage());
|
||||
} finally {
|
||||
closeStoreContext(storeContext);
|
||||
}
|
||||
}
|
||||
|
||||
protected Boolean storeDocument(Document entity, List<DocumentChunk> documentChunks) {
|
||||
StoreExecutionContext storeContext = prepareStoreContext(entity);
|
||||
storeDocumentChunks(storeContext, documentChunks);
|
||||
updateKnowledgeAfterStore(storeContext);
|
||||
return true;
|
||||
try {
|
||||
storeDocumentChunks(storeContext, documentChunks);
|
||||
updateKnowledgeAfterStore(storeContext);
|
||||
return true;
|
||||
} finally {
|
||||
closeStoreContext(storeContext);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -430,14 +441,16 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
}
|
||||
|
||||
StoreExecutionContext storeContext = prepareStoreContext(document);
|
||||
storeDocumentChunks(storeContext, session.getDocumentChunks());
|
||||
try {
|
||||
storeDocumentChunks(storeContext, session.getDocumentChunks());
|
||||
persistDocumentWithChunks(document, session.getDocumentChunks());
|
||||
updateKnowledgeAfterStore(storeContext);
|
||||
} catch (Exception e) {
|
||||
cleanupPersistedDocument(document);
|
||||
rollbackStoredChunks(storeContext, session.getDocumentChunks());
|
||||
throw new BusinessException("提交导入失败:" + e.getMessage());
|
||||
} finally {
|
||||
closeStoreContext(storeContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,24 +764,28 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
if (documentStore == null) {
|
||||
throw new BusinessException("向量数据库配置错误");
|
||||
}
|
||||
try {
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置大模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置大模型");
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
|
||||
DocumentSearcher searcher = null;
|
||||
searcher = searcherFactory.getSearcher();
|
||||
return new StoreExecutionContext(knowledge, model, embeddingModel, documentStore, options, searcher);
|
||||
} catch (RuntimeException e) {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
throw e;
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
|
||||
StoreOptions options = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(knowledge.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
|
||||
DocumentSearcher searcher = null;
|
||||
searcher = searcherFactory.getSearcher();
|
||||
return new StoreExecutionContext(knowledge, model, embeddingModel, documentStore, options, searcher);
|
||||
}
|
||||
|
||||
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
||||
@@ -841,6 +858,13 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
||||
}
|
||||
}
|
||||
|
||||
private void closeStoreContext(StoreExecutionContext storeContext) {
|
||||
if (storeContext == null) {
|
||||
return;
|
||||
}
|
||||
DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
|
||||
}
|
||||
|
||||
private void persistDocumentWithChunks(Document document, List<DocumentChunk> chunks) {
|
||||
this.getMapper().insert(document);
|
||||
AtomicInteger sort = new AtomicInteger(1);
|
||||
|
||||
@@ -40,6 +40,7 @@ import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.FaqCategoryService;
|
||||
import tech.easyflow.ai.service.FaqItemService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.ai.vo.FaqImportErrorRowVo;
|
||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
@@ -348,29 +349,37 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
||||
|
||||
private void storeToVector(DocumentCollection collection, FaqItem entity, boolean isUpdate) {
|
||||
PreparedStore preparedStore = prepareStore(collection);
|
||||
com.easyagents.core.document.Document doc = toSearchDocument(entity);
|
||||
StoreResult result = isUpdate
|
||||
? preparedStore.documentStore.update(doc, preparedStore.storeOptions)
|
||||
: preparedStore.documentStore.store(Collections.singletonList(doc), preparedStore.storeOptions);
|
||||
if (result == null || !result.isSuccess()) {
|
||||
throw new BusinessException("FAQ向量化失败");
|
||||
}
|
||||
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (searcher != null) {
|
||||
if (isUpdate) {
|
||||
searcher.deleteDocument(entity.getId());
|
||||
try {
|
||||
com.easyagents.core.document.Document doc = toSearchDocument(entity);
|
||||
StoreResult result = isUpdate
|
||||
? preparedStore.documentStore.update(doc, preparedStore.storeOptions)
|
||||
: preparedStore.documentStore.store(Collections.singletonList(doc), preparedStore.storeOptions);
|
||||
if (result == null || !result.isSuccess()) {
|
||||
throw new BusinessException("FAQ向量化失败");
|
||||
}
|
||||
searcher.addDocument(doc);
|
||||
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
if (searcher != null) {
|
||||
if (isUpdate) {
|
||||
searcher.deleteDocument(entity.getId());
|
||||
}
|
||||
searcher.addDocument(doc);
|
||||
}
|
||||
markCollectionEmbedded(collection, preparedStore.embeddingModel);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(preparedStore.documentStore);
|
||||
}
|
||||
markCollectionEmbedded(collection, preparedStore.embeddingModel);
|
||||
}
|
||||
|
||||
private void removeFromVector(DocumentCollection collection, FaqItem entity) {
|
||||
PreparedStore preparedStore = prepareStore(collection);
|
||||
boolean deleteSuccess = deleteFromVectorStore(preparedStore.documentStore, preparedStore.storeOptions, entity.getId());
|
||||
if (!deleteSuccess) {
|
||||
throw new BusinessException("FAQ向量删除失败");
|
||||
try {
|
||||
boolean deleteSuccess = deleteFromVectorStore(preparedStore.documentStore, preparedStore.storeOptions, entity.getId());
|
||||
if (!deleteSuccess) {
|
||||
throw new BusinessException("FAQ向量删除失败");
|
||||
}
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(preparedStore.documentStore);
|
||||
}
|
||||
|
||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||
@@ -413,20 +422,25 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
||||
if (documentStore == null) {
|
||||
throw new BusinessException("向量数据库配置错误");
|
||||
}
|
||||
Model model = modelService.getModelInstance(collection.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
try {
|
||||
Model model = modelService.getModelInstance(collection.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("该知识库未配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
|
||||
StoreOptions options = StoreOptions.ofCollectionName(collection.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(collection.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
return new PreparedStore(documentStore, options, embeddingModel);
|
||||
StoreOptions options = StoreOptions.ofCollectionName(collection.getVectorStoreCollection());
|
||||
EmbeddingOptions embeddingOptions = new EmbeddingOptions();
|
||||
embeddingOptions.setModel(model.getModelName());
|
||||
embeddingOptions.setDimensions(collection.getDimensionOfVectorModel());
|
||||
options.setEmbeddingOptions(embeddingOptions);
|
||||
options.setIndexName(options.getCollectionName());
|
||||
return new PreparedStore(documentStore, options, embeddingModel);
|
||||
} catch (RuntimeException e) {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private com.easyagents.core.document.Document toSearchDocument(FaqItem entity) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.ai.service.FaqItemService;
|
||||
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
@@ -50,20 +51,24 @@ public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService
|
||||
if (documentStore == null) {
|
||||
throw new BusinessException("知识库没有配置向量库");
|
||||
}
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions storeOptions = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
storeOptions.setIndexName(knowledge.getVectorStoreCollection());
|
||||
try {
|
||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||
if (model == null) {
|
||||
throw new BusinessException("知识库没有配置向量模型");
|
||||
}
|
||||
EmbeddingModel embeddingModel = model.toEmbeddingModel();
|
||||
documentStore.setEmbeddingModel(embeddingModel);
|
||||
StoreOptions storeOptions = StoreOptions.ofCollectionName(knowledge.getVectorStoreCollection());
|
||||
storeOptions.setIndexName(knowledge.getVectorStoreCollection());
|
||||
|
||||
if (knowledge.isFaqCollection()) {
|
||||
rebuildFaqVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||
return;
|
||||
if (knowledge.isFaqCollection()) {
|
||||
rebuildFaqVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||
return;
|
||||
}
|
||||
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||
} finally {
|
||||
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||
}
|
||||
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||
}
|
||||
|
||||
private void rebuildDocumentVectors(
|
||||
@@ -153,4 +158,3 @@ public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService
|
||||
documentCollectionService.updateById(update);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.ai.support;
|
||||
|
||||
import com.easyagents.core.store.DocumentStore;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 文档向量库生命周期辅助工具。
|
||||
*/
|
||||
public final class DocumentStoreLifecycleSupport {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DocumentStoreLifecycleSupport.class);
|
||||
|
||||
private DocumentStoreLifecycleSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭支持关闭语义的文档向量库。
|
||||
*
|
||||
* @param documentStore 文档向量库实例
|
||||
*/
|
||||
public static void closeQuietly(DocumentStore documentStore) {
|
||||
if (!(documentStore instanceof AutoCloseable)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
((AutoCloseable) documentStore).close();
|
||||
} catch (Exception e) {
|
||||
LOG.warn("关闭文档向量库连接失败: store={}", documentStore.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 健康检查短缓存测试。
|
||||
*/
|
||||
public class CachedHealthIndicatorSupportTest {
|
||||
|
||||
/**
|
||||
* 验证 TTL 内重复健康检查复用缓存。
|
||||
*/
|
||||
@Test
|
||||
public void shouldReuseHealthWithinCacheTtl() {
|
||||
RagHealthProperties properties = new RagHealthProperties();
|
||||
properties.setCacheTtl(Duration.ofSeconds(5));
|
||||
MutableClock clock = new MutableClock();
|
||||
CountingHealthIndicator indicator = new CountingHealthIndicator(properties, clock);
|
||||
|
||||
indicator.cachedHealth();
|
||||
indicator.cachedHealth();
|
||||
|
||||
Assert.assertEquals(1, indicator.count());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 TTL 过期后重新执行健康检查。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRefreshHealthAfterCacheExpired() {
|
||||
RagHealthProperties properties = new RagHealthProperties();
|
||||
properties.setCacheTtl(Duration.ofSeconds(5));
|
||||
MutableClock clock = new MutableClock();
|
||||
CountingHealthIndicator indicator = new CountingHealthIndicator(properties, clock);
|
||||
|
||||
indicator.cachedHealth();
|
||||
clock.plus(Duration.ofSeconds(6));
|
||||
indicator.cachedHealth();
|
||||
|
||||
Assert.assertEquals(2, indicator.count());
|
||||
}
|
||||
|
||||
private static class CountingHealthIndicator extends CachedHealthIndicatorSupport {
|
||||
|
||||
private final AtomicInteger counter = new AtomicInteger();
|
||||
|
||||
private CountingHealthIndicator(RagHealthProperties properties, Clock clock) {
|
||||
super(properties, clock);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Health doHealthCheck() {
|
||||
counter.incrementAndGet();
|
||||
return Health.up().build();
|
||||
}
|
||||
|
||||
private int count() {
|
||||
return counter.get();
|
||||
}
|
||||
}
|
||||
|
||||
private static class MutableClock extends Clock {
|
||||
|
||||
private Instant instant = Instant.parse("2026-05-25T00:00:00Z");
|
||||
|
||||
@Override
|
||||
public ZoneId getZone() {
|
||||
return ZoneId.of("UTC");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return instant;
|
||||
}
|
||||
|
||||
private void plus(Duration duration) {
|
||||
instant = instant.plus(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user