RagTranslationApplicationService.java
package com.yumu.noveltranslator.application.service;
import com.yumu.noveltranslator.domain.service.TranslationMemoryService;
import com.yumu.noveltranslator.domain.model.TranslationMemory;
import com.yumu.noveltranslator.port.out.EmbeddingPort;
import com.yumu.noveltranslator.port.dto.translation.RagTranslationResponse;
import com.yumu.noveltranslator.port.out.VectorSearchResult;
import com.yumu.noveltranslator.port.out.VectorStorePort;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* RAG 翻译服务
* 核心查询链路:生成向量 → Redis KNN 查询(按 user_id 过滤) → 相似度决策
* Redis 失败时降级为 MySQL 余弦相似度计算
* 翻译完成后自动存储到翻译记忆
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class RagTranslationApplicationService implements com.yumu.noveltranslator.port.in.RagTranslationPort {
private static final String SOURCE_LANG_AUTO = "auto";
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
private final EmbeddingPort embeddingPort;
private final TranslationMemoryService translationMemoryService;
private final VectorStorePort vectorStorePort;
@Value("${embedding.provider:openai}")
private String provider;
@Value("${embedding.rag.direct-hit-threshold:0.85}")
private double directHitThreshold;
@Value("${embedding.rag.reference-threshold:0.5}")
private double referenceThreshold;
@Value("${embedding.rag.knn-top-k:5}")
private int knnTopK;
@Value("${embedding.rag.fallback-mysql-limit:20}")
private int fallbackMysqlLimit;
/**
* 查询相似翻译记忆(指定 userId,带模式层级过滤)
*/
public RagTranslationResponse searchSimilarWithModes(
Long userId, String sourceText, String targetLang, List<String> allowedModes) {
if (userId == null) {
return buildEmptyResponse();
}
return doSearch(sourceText, targetLang, userId, allowedModes);
}
// ==================== 存储入口 ====================
public void storeTranslationMemory(String sourceText, String targetText, String targetLang, String engine, Long userId, String translationMode) {
doStore(sourceText, targetText, targetLang, engine, userId, translationMode);
}
// ==================== 核心私有方法 ====================
private RagTranslationResponse doSearch(String sourceText, String targetLang, Long userId, List<String> allowedModes) {
if (sourceText == null || sourceText.isBlank()) {
return buildEmptyResponse();
}
try {
float[] queryVector = embeddingPort.embed(sourceText);
if (queryVector.length == 0) {
return buildEmptyResponse();
}
List<VectorSearchResult> results = vectorStorePort.vectorSearch(queryVector, userId, targetLang, allowedModes, knnTopK);
if (results.isEmpty()) {
results = searchFallback(queryVector, userId, targetLang, allowedModes);
}
if (results.isEmpty()) {
return buildEmptyResponse();
}
RagTranslationResponse response = new RagTranslationResponse();
response.setMatches(toRagMatches(results));
VectorSearchResult best = results.get(0);
if (best.similarity() >= directHitThreshold) {
response.setDirectHit(true);
response.setTranslation(best.targetText());
response.setSimilarity(best.similarity());
translationMemoryService.incrementUsage(best.memoryId());
log.info("RAG 直接命中: similarity={}, memoryId={}", best.similarity(), best.memoryId());
} else if (best.similarity() >= referenceThreshold) {
response.setDirectHit(false);
response.setSimilarity(best.similarity());
log.info("RAG 提供参考: similarity={}", best.similarity());
}
return response;
} catch (Exception e) {
log.error("RAG 查询失败: {}", e.getMessage(), e);
return buildEmptyResponse();
}
}
private void doStore(String sourceText, String targetText, String targetLang, String engine, Long userId, String translationMode) {
if (userId == null || sourceText == null || sourceText.isBlank()) {
return;
}
String rejectionReason = rejectQuality(sourceText, targetText);
if (rejectionReason != null) {
log.debug("RAG 质量筛选拦截: reason={}, engine={}, sourceLen={}", rejectionReason, engine, sourceText.length());
return;
}
try {
Long memoryId = translationMemoryService.storeTranslation(
sourceText, targetText, SOURCE_LANG_AUTO, targetLang,
userId, null, engine, translationMode);
if (memoryId == null) {
log.warn("RAG 存储失败: storeTranslation 返回 null memoryId");
return;
}
float[] embedding = embeddingPort.embed(sourceText);
if (embedding.length > 0) {
vectorStorePort.storeVector(
memoryId, sourceText, targetText, SOURCE_LANG_AUTO, targetLang,
userId, translationMode, embedding);
}
log.debug("RAG 存储翻译记忆: memoryId={}, sourceLen={}, mode={}", memoryId, sourceText.length(), translationMode);
} catch (Exception e) {
log.error("RAG 存储翻译记忆失败: {}", e.getMessage(), e);
}
}
// ==================== 内部辅助方法 ====================
private List<RagTranslationResponse.RagMatch> toRagMatches(List<VectorSearchResult> results) {
List<RagTranslationResponse.RagMatch> matches = new ArrayList<>(results.size());
for (VectorSearchResult r : results) {
RagTranslationResponse.RagMatch match = new RagTranslationResponse.RagMatch();
match.setMemoryId(r.memoryId());
match.setSourceText(r.sourceText());
match.setTargetText(r.targetText());
match.setSimilarity(r.similarity());
matches.add(match);
}
return matches;
}
/**
* MySQL 降级方案:查询用户的翻译记忆,用余弦相似度计算匹配度(带模式过滤)
*/
private List<VectorSearchResult> searchFallback(float[] queryVector, Long userId, String targetLang, List<String> allowedModes) {
try {
List<TranslationMemory> memories = translationMemoryService
.searchByUserAndLang(userId, SOURCE_LANG_AUTO, targetLang, fallbackMysqlLimit);
if (memories.isEmpty()) {
return Collections.emptyList();
}
Set<String> modeSet = allowedModes != null ? Set.copyOf(allowedModes) : Collections.emptySet();
List<VectorSearchResult> results = new ArrayList<>();
for (TranslationMemory memory : memories) {
if (!modeSet.isEmpty() && memory.getTranslationMode() != null
&& !modeSet.contains(memory.getTranslationMode())) {
continue;
}
List<Float> storedEmbedding = memory.getEmbedding();
if (storedEmbedding == null || storedEmbedding.isEmpty()) {
continue;
}
double similarity = cosineSimilarity(queryVector, storedEmbedding);
if (similarity >= referenceThreshold) {
results.add(new VectorSearchResult(
memory.getId(), memory.getSourceText(), memory.getTargetText(), similarity));
}
}
results.sort((a, b) -> Double.compare(b.similarity(), a.similarity()));
return results;
} catch (Exception e) {
log.warn("MySQL 降级查询失败: {}", e.getMessage());
return Collections.emptyList();
}
}
/**
* 计算余弦相似度
*/
private double cosineSimilarity(float[] vectorA, List<Float> vectorB) {
if (vectorA.length != vectorB.size()) {
return 0.0;
}
double dotProduct = 0.0;
double normA = 0.0;
double normB = 0.0;
for (int i = 0; i < vectorA.length; i++) {
float a = vectorA[i];
float b = vectorB.get(i);
dotProduct += a * b;
normA += a * a;
normB += b * b;
}
if (normA == 0.0 || normB == 0.0) {
return 0.0;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* 质量筛选:返回拒绝原因(如果通过则返回 null)
*/
private String rejectQuality(String source, String target) {
if (target == null || target.isBlank()) {
return "empty_target";
}
if (source.trim().equalsIgnoreCase(target.trim())) {
return "identical_to_source";
}
int sourceLen = WHITESPACE.matcher(source).replaceAll("").length();
int targetLen = WHITESPACE.matcher(target).replaceAll("").length();
if (sourceLen > 0) {
double ratio = (double) targetLen / sourceLen;
if (ratio > 10.0) {
return "length_ratio_too_high:" + String.format("%.1f", ratio);
}
if (ratio < 0.1) {
return "length_ratio_too_low:" + String.format("%.2f", ratio);
}
}
String[] adKeywords = {"人工智能助手", "Gemini", "GPT-4", "Claude", "ChatGPT",
"powered by", "generated by", "翻译引擎", "未翻译", "translation pending"};
String targetLower = target.toLowerCase(Locale.ROOT);
for (String keyword : adKeywords) {
if (targetLower.contains(keyword.toLowerCase(Locale.ROOT))) {
return "ad_keyword:" + keyword;
}
}
if (targetLen > 10) {
int nonAlphanumeric = 0;
for (char c : target.toCharArray()) {
if (!Character.isLetterOrDigit(c) && !Character.isWhitespace(c)) {
nonAlphanumeric++;
}
}
if ((double) nonAlphanumeric / targetLen > 0.6) {
return "too_many_special_chars";
}
}
return null;
}
public void clearAllTranslationMemory() {
try {
translationMemoryService.deleteAllTranslationMemory();
log.info("MySQL translation_memory 表已清空");
} catch (Exception e) {
log.warn("MySQL translation_memory 清空失败:{}", e.getMessage());
}
try {
vectorStorePort.clearAllVectors();
log.info("向量索引已清空");
} catch (Exception e) {
log.warn("向量索引清空失败:{}", e.getMessage());
}
}
private RagTranslationResponse buildEmptyResponse() {
RagTranslationResponse response = new RagTranslationResponse();
response.setDirectHit(false);
response.setMatches(new ArrayList<>());
return response;
}
}