语音合成技术演进与实战:从TTS原理到Unity/安卓离线集成
2026/7/30 5:03:56
pom.xml)xml
<dependencies> <!-- Spring Boot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Elasticsearch 8.x 官方 Java Client --> <dependency> <groupId>co.elastic.clients</groupId> <artifactId>elasticsearch-java</artifactId> <version>8.12.0</version> </dependency> <!-- ES Client 底层通信依赖 --> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-client</artifactId> <version>8.12.0</version> </dependency> <!-- Jackson JSON 处理 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies>EmbeddingService.java)在实际业务中,这里会替换为调用 OpenAI、通义千问等真实 API 的代码。这里为了演示,我们生成一个固定维度的随机向量。
java
import org.springframework.stereotype.Service; import java.util.Random; @Service public class EmbeddingService { private static final int VECTOR_DIM = 768; // 假设模型输出 768 维 private final Random random = new Random(); /** * 将文本转换为向量 * 【实际业务中:在此处通过 HTTP 调用大模型 Embedding API】 */ public float[] embed(String text) { // 模拟生成 768 维的随机向量 float[] vector = new float[VECTOR_DIM]; for (int i = 0; i < VECTOR_DIM; i++) { vector[i] = random.nextFloat(); } return vector; } }ElasticsearchConfig.java)java
import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.json.jackson.JacksonJsonpMapper; import co.elastic.clients.transport.ElasticsearchTransport; import co.elastic.clients.transport.rest_client.RestClientTransport; import org.apache.http.HttpHost; import org.elasticsearch.client.RestClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class ElasticsearchConfig { @Bean public RestClient restClient() { return RestClient.builder(new HttpHost("localhost", 9200, "http")).build(); } @Bean public ElasticsearchClient elasticsearchClient(RestClient restClient) { ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); return new ElasticsearchClient(transport); } }VectorController.java)这个 Controller 包含了创建索引、写入文档(自动调用 Embedding)、以及 kNN 检索的完整流程。
java
import co.elastic.clients.elasticsearch.ElasticsearchClient; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import org.springframework.web.bind.annotation.*; import java.util.*; @RestController @RequestMapping("/api/vector") public class VectorController { private final ElasticsearchClient esClient; private final EmbeddingService embeddingService; private static final String INDEX_NAME = "my_native_vector_index"; private static final int VECTOR_DIM = 768; public VectorController(ElasticsearchClient esClient, EmbeddingService embeddingService) { this.esClient = esClient; this.embeddingService = embeddingService; } /** * 1. 初始化索引 (只需调用一次) */ @PostMapping("/init-index") public String initIndex() throws Exception { boolean exists = esClient.indices().exists(e -> e.index(INDEX_NAME)).value(); if (exists) return "索引已存在,无需重复创建"; esClient.indices().create(c -> c .index(INDEX_NAME) .mappings(m -> m .properties("content", p -> p.text(t -> t)) .properties("content_vector", p -> p.denseVector(dv -> dv .dims(VECTOR_DIM) .index(true) .similarity("cosine") )) ) ); return "索引创建成功!"; } /** * 2. 写入文档 (核心:在这里调用 Embedding 模型,并将向量放入文档) */ @PostMapping("/add") public String addDocument(@RequestBody Map<String, String> payload) throws Exception { String content = payload.get("content"); // 【关键步骤】调用模型生成向量 float[] vector = embeddingService.embed(content); // 组装文档,必须包含 content_vector 字段 Map<String, Object> doc = new HashMap<>(); doc.put("content", content); doc.put("content_vector", vector); // 将生成的向量塞入文档 // 写入 ES esClient.index(i -> i.index(INDEX_NAME).document(doc)); return "文档向量化并存储成功!"; } /** * 3. kNN 向量检索 */ @GetMapping("/search") public List<Map<String, Object>> search(@RequestParam String query, @RequestParam(defaultValue = "5") int topK) throws Exception { // 【关键步骤】查询文本也需要调用模型生成向量 float[] queryVector = embeddingService.embed(query); SearchResponse<Map> response = esClient.search(s -> s .index(INDEX_NAME) .knn(k -> k .field("content_vector") .queryVector(queryVector) .k(topK) .numCandidates(topK * 10) ), Map.class ); // 格式化返回结果 List<Map<String, Object>> results = new ArrayList<>(); for (Hit<Map> hit : response.hits().hits()) { Map<String, Object> map = new HashMap<>(); map.put("score", hit.score()); map.put("content", hit.source().get("content")); results.add(map); } return results; } }ES索引示例
PUT /vector_docs { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "index.knn": true, // 必须开启KNN插件总开关 "index.knn.algo_param.ef_search": 100 }, "mappings": { "properties": { "content": { "type": "text" }, // 原始文本,全文检索 "doc_id": { "type": "keyword" }, "embedding": { "type": "dense_vector", "dims": 1536, "index": true, "index_options": { "type": "hnsw" }, "similarity": "cosine" } } } }注意:这里dims要和embedding模型维度一致,维度由embedding决定(特例:动态embedding可以指定维度)
addDocument接口中,先调用embeddingService.embed(content)拿到float[],然后doc.put("content_vector", vector),最后交给 ES 存储。search接口中,用户的查询语句query也必须先经过embeddingService.embed(query)变成向量,才能传给 ES 的.queryVector(queryVector)进行比对。