承上:上一篇我们把文档切成了高质量的小碎片,但它们还只是文本。AI不认识文本,只认识数字。今天,我们要把这些文本碎片变成一串串数字——向量,存入向量数据库,让AI真正能"理解"你的私有知识。
1. 先搞懂:什么是Embedding?
1.1. 用后端老鸟的类比
假设你是数据库管理员,要给1000本书建立索引:
1传统索引: 2"Java编程思想" → 按书名倒排 → J字母开头 → 第3排第5本 3 4向量索引: 5"Java编程思想" → 转成一串数字[0.23, -0.15, 0.78, ...] → 6 在1536维空间里找一个坐标点 → 7 查"Java入门"时 → 也转成坐标 → 找最近的点 → 就是这本书! 8
Embedding 就是把文本映射到高维空间的坐标点。语义越相似,坐标越接近。
1"年假怎么请" → [0.12, -0.34, 0.56, ...] ← 1536个数字 2"休假政策" → [0.13, -0.32, 0.54, ...] ← 坐标非常接近! 3"今天天气" → [-0.78, 0.45, -0.23, ...] ← 坐标很远 4
Spring AI 2.0 通过统一的 EmbeddingModel 接口支持多种模型:
| 模型 | 维度 | 最大输入 | 价格 |
|---|---|---|---|
| text-embedding-ada-002(OpenAI) | 1536 | 8191 Token | ¥0.008/千Token |
| text-embedding-3-small(OpenAI) | 1536 | 8191 Token | ¥0.002/千Token |
| 通义千问 Embedding | 1536 | 2048 Token | ¥0.0007/千Token |
本篇使用通义千问的Embedding模型,和我们的对话模型一致,国内访问稳定,价格便宜。
2. 第一步:配置Embedding模型
2.1. 添加依赖
1<dependency> 2 <groupId>org.springframework.ai</groupId> 3 <artifactId>spring-ai-starter-openai</artifactId> 4</dependency> 5
注意:和之前的对话模型用同一个Starter。通义千问的Embedding也通过OpenAI兼容接口提供。
2.2. 配置Embedding
1spring: 2 ai: 3 openai: 4 api-key: xxxx 5 base-url: https://ws-xxxx.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 6 chat: 7 options: 8 model: qwen3.7-max 9 embedding: 10 options: 11 model: qwen3.7-text-embedding 12
2.3. 第一个Embedding测试
1package com.oldbird.ai.chapter13.embedding; 2 3import org.springframework.ai.embedding.EmbeddingModel; 4import org.springframework.ai.embedding.EmbeddingResponse; 5import org.springframework.stereotype.Component; 6 7import java.util.List; 8 9@Component 10public class EmbeddingDemo { 11 12 private final EmbeddingModel embeddingModel; 13 14 public EmbeddingDemo(EmbeddingModel embeddingModel) { 15 this.embeddingModel = embeddingModel; 16 } 17 18 public void testEmbedding() { 19 // 将文本转成向量 20 EmbeddingResponse response = embeddingModel.embedForResponse( 21 List.of("入职满1年享有5天带薪年假", "年假怎么申请") 22 ); 23 24 List<float[]> vectors = response.getResults().stream() 25 .map(result -> result.getOutput()) 26 .toList(); 27 28 System.out.println("向量维度:" + vectors.get(0).length); // 1536 29 System.out.println("第一个向量前5个值:" + 30 vectors.get(0)[0] + ", " + 31 vectors.get(0)[1] + ", " + 32 vectors.get(0)[2] + ", " + 33 vectors.get(0)[3] + ", " + 34 vectors.get(0)[4]); 35 36 // 计算相似度 37 double similarity = cosineSimilarity(vectors.get(0), vectors.get(1)); 38 System.out.println("两句话的余弦相似度:" + similarity); 39 // 输出:0.92(高度相似) 40 } 41 42 /** 43 * 余弦相似度:衡量两个向量在多维空间中的夹角 44 * 1.0 = 完全同向(语义相同) 45 * 0.0 = 正交(无关) 46 * -1.0 = 完全反向(语义相反) 47 */ 48 private double cosineSimilarity(float[] a, float[] b) { 49 double dotProduct = 0; 50 double normA = 0; 51 double normB = 0; 52 for (int i = 0; i < a.length; i++) { 53 dotProduct += a[i] * b[i]; 54 normA += a[i] * a[i]; 55 normB += b[i] * b[i]; 56 } 57 return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); 58 } 59} 60
运行结果:
1向量维度:1024 2第一个向量前5个值:0.023570376, -0.009924047, -0.025762115, 0.0029585415, 0.02347242 3两句话的余弦相似度:0.643651355275984 4
关键认知:文本→1024个浮点数→一个坐标。从此以后,"语义相似"可以用"坐标距离"来数学计算。这就是AI能"理解"文本的底层原理。
3. 第二步:选择向量数据库
3.1. 主流向量的数据库对比
向量数据的选型我没有经验可谈哈,但是用于学习实战和公司小型知识库我觉得redis足够。
| 数据库 | 类型 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| Redis Stack | 内存+磁盘 | 已有Redis可复用,毫秒级 | 内存贵,大规模成本高 | 中小规模、已有Redis |
| Milvus | 专业向量库 | 十亿级向量,索引丰富 | 独立部署,运维复杂 | 大规模生产 |
| PGVector | PostgreSQL插件 | 和业务库一体,SQL查询 | 百万级以上性能下降 | 已有PostgreSQL |
| Elasticsearch | 搜索引擎 | 混合检索(关键词+向量) | 向量检索非原生 | 已有ES |
3.2. 本篇选择:Redis Stack
理由:
- 上一篇第9篇(ChatMemory)已经用了Redis,不引入新中间件
- 开发阶段零成本,Docker一行命令搞定
- 向量检索性能足够(百万级以下)
3.3. 部署Redis Stack
1docker run -d \ 2 --name redis-stack \ 3 -p 6379:6379 \ 4 -p 8001:8001 \ 5 redis/redis-stack:latest 6
Redis Stack 包含了 RediSearch 模块,支持向量检索。普通Redis不行。
4. 第三步:向量化并写入Redis
4.1. 添加依赖
1<dependency> 2 <groupId>org.springframework.ai</groupId> 3 <artifactId>spring-ai-starter-vector-store-redis</artifactId> 4</dependency> 5
4.2. 配置向量存储Bean
1package com.yunxi.ai.config; 2 3import org.springframework.ai.embedding.EmbeddingModel; 4import org.springframework.ai.vectorstore.redis.RedisVectorStore; 5import org.springframework.beans.factory.annotation.Value; 6import org.springframework.context.annotation.Bean; 7import org.springframework.context.annotation.Configuration; 8import redis.clients.jedis.JedisPoolConfig; 9import redis.clients.jedis.RedisClient; 10 11@Configuration 12public class VectorStoreConfig { 13 @Bean 14 public RedisVectorStore vectorStore(RedisClient jedisRedisClient, EmbeddingModel embeddingModel) { 15 return RedisVectorStore.builder(jedisRedisClient, embeddingModel) 16 .indexName("spring-ai-knowledge") 17 .prefix("doc:") 18 .metadataFields( 19 RedisVectorStore.MetadataField.tag("source") 20 ) 21 .initializeSchema(true) 22 .build(); 23 } 24} 25
4.3. 完整的导入服务
1package com.yunxi.ai.controller; 2 3import com.yunxi.ai.entity.Result; 4import lombok.extern.slf4j.Slf4j; 5import org.springframework.ai.document.Document; 6import org.springframework.ai.reader.tika.TikaDocumentReader; 7import org.springframework.ai.transformer.splitter.TokenTextSplitter; 8import org.springframework.ai.vectorstore.VectorStore; 9import org.springframework.beans.factory.annotation.Autowired; 10import org.springframework.core.io.Resource; 11import org.springframework.web.bind.annotation.RequestMapping; 12import org.springframework.web.bind.annotation.RequestParam; 13import org.springframework.web.bind.annotation.RestController; 14import org.springframework.web.multipart.MultipartFile; 15 16import java.util.List; 17 18@RestController 19@RequestMapping("/rag") 20@Slf4j 21public class RagController { 22 23 /** 24 * 导入文档到向量数据库 25 * 完整流程:读取 → 分割 → 向量化 → 存储 26 */ 27 @RequestMapping("/uploadAndEmbedding") 28 public Result uploadAndEmbedding(@RequestParam("file") MultipartFile file, @RequestParam(value = "chunkSize", defaultValue = "500") Integer chunkSize) { 29 if (file == null || file.isEmpty()) { 30 return Result.failed(500, "文件为空"); 31 } 32 try { 33 log.info("开始处理文件:{},分片大小:{}", file.getOriginalFilename(), chunkSize); 34 Resource resource = file.getResource(); 35 TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource); 36 List<Document> read = tikaDocumentReader.read(); 37 log.info("文件解析完成,原始文档数:{}", read.size()); 38 TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(chunkSize).build(); 39 List<Document> apply = splitter.apply(read); 40 log.info("分片完成,总分片数:{}", apply.size()); 41 // 向量化存储 42 vectorStore.add(apply); 43 return Result.successed(apply); 44 } catch (Exception e) { 45 log.error("文件处理异常", e); 46 return Result.failed(500, "文件处理异常:" + e.getMessage()); 47 } 48 } 49 50} 51
核心代码就一句:vectorStore.add(apply);
4.4. 验证导入
生成了10个分片,Redis里发生了什么?
1Key: doc:4dd6f53b-bd60-4327-b566-131247fcf9ee 2Type: Hash 3Value: { 4 "chunk_index": 4, 5 "parent_document_id": "5f0a7d4a-b53f-4cc6-b5fd-8b6e5541120c", 6 "embedding": [ 7 0.01784777, 8 0.014171031, 9 -0.020203378, 10 -0.052795503... 11 ], 12 "source": "1001.docx", 13 "total_chunks": 10, 14 "content": "- 迟到:未在规定上班时间内完成有效打卡,且无有效请假、外勤审批记录的行为\n- 早退:未在规定下班时间完成有效打卡提前离岗,且无有效审批记录的行为\n4.2 梯度处罚标准\n1. 月度内单次迟到/早退时长在10分钟以内,累计不超过3次的,不予扣款,给予口头提醒\n2. 月度内迟到/早退累计超过3次,或单次时长在10-30分钟的,每次扣除当月全勤奖的20%\n3. 月度内迟到/早退累计超过5次,或单次时长在30-60分钟的,每次扣除当日工资的20%,全公司通报批评\n4. 月度内单次迟到/早退时长超过60分钟的,按旷工半天核算,扣除当日全额工资\n5. 年度内迟到/早退累计超过36次的,取消当年年度调薪、晋升资格,人力资源部下发绩效改进通知\n4.3 特殊豁免场景\n员工因暴雨、暴雪、地铁故障等不可抗力公共事件导致迟到的,经行政部核实后可豁免迟到处罚,不纳入月度考勤统计。\n第五章 旷工管理\n5.1 旷工判定情形\n员工出现以下任意一种情形,直接判定为旷工:\n1." 15} 16
每个文档块都存了:原始文本 + 元数据 + 向量。
5. 第四步:向量检索
5.1. 检索逻辑
1 /** 2 * 向量检索 3 */ 4 @RequestMapping("/searchEmbedding") 5 public Result searchEmbedding(String query, Integer topK) { 6 try{ 7 SearchRequest request = SearchRequest.builder() 8 .query(query) 9 .topK(topK) 10 .build(); 11 List<Document> results = vectorStore.similaritySearch(request); 12 return Result.successed(results); 13 } catch (Exception e) { 14 return Result.failed(500, "系统异常"); 15 } 16 } 17
5.2. 测试检索效果
查询1:年假相关
分析:第一个结果精准命中"年假"内容。第二个和第三个虽然也是休假相关,但和年假不完全匹配——这就是向量检索的特点:找语义相似的,不一定是关键词完全匹配的。
查询2:长期超过50天
用户问的是"出差超过50天",AI精准检索到了"长期出差"那段,长期出差超过30天。用户可能用了口语化表达,但向量检索理解了他的语义。
6. 向量数据库里到底存了什么?
用Redis CLI看一下:
1# 查看索引信息 2redis-cli FT.INFO spring-ai-knowledge 3FT.INFO spring-ai-knowledge 41) "key_table_size_mb" 52) 0.0001659393310546875 63) "geoshapes_sz_mb" 74) 0 85) "bytes_per_record_avg" 96) 89.2532730102539 107) "index_name" 118) "spring-ai-knowledge" 129) "attributes" 1310) 1) 1) "identifier" 14 2) "$.content" 15 3) "attribute" 16 4) "content" 17 5) "type" 18 6) "TEXT" 19 7) "WEIGHT" 20 8) 1 21 9) "flags" 22 10) 23 2) 1) "algorithm" 24 2) "HNSW" 25 3) "distance_metric" 26 4) "COSINE" 27 5) "flags" 28 6) 29 7) "identifier" 30 8) "$.embedding" 31 9) "data_type" 32 10) "FLOAT32" 33 11) "dim" 34 12) 1024 35 13) "M" 36 14) 16 37 15) "ef_construction" 38 16) 200 39 17) "attribute" 40 18) "embedding" 41 19) "type" 42 20) "VECTOR" 43 3) 1) "identifier" 44 2) "$.source" 45 3) "attribute" 46 4) "source" 47 5) "type" 48 6) "TAG" 49 7) "SEPARATOR" 50 8) "" 51 9) "flags" 52 10) 5311) "sortable_values_size_mb" 5412) 0 5513) "offset_bits_per_record_avg" 5614) 8 5715) "number_of_uses" 5816) 1 5917) "num_records" 6018) 229 6119) "total_index_memory_sz_mb" 6220) 0.043750762939453125 6321) "cleaning" 6422) 0 6523) "inverted_sz_mb" 6624) 0.019492149353027344 6725) "gc_stats" 6826) 1) "bytes_collected" 69 2) 0 70 3) "total_ms_run" 71 4) 0 72 5) "total_cycles" 73 6) 0 74 7) "average_cycle_time_ms" 75 8) NaN 76 9) "last_run_time_ms" 77 10) 0 78 11) "gc_numeric_trees_missed" 79 12) 0 80 13) "gc_blocks_denied" 81 14) 0 8227) "num_terms" 8328) 196 8429) "offset_vectors_sz_mb" 8530) 0.000385284423828125 8631) "doc_table_size_mb" 8732) 0.015834808349609375 8833) "records_per_doc_avg" 8934) 45.79999923706055 9035) "offsets_per_term_avg" 9136) 1.7641921043395996 9237) "dialect_stats" 9338) 1) "dialect_1" 94 2) 0 95 3) "dialect_2" 96 4) 0 97 5) "dialect_3" 98 6) 0 99 7) "dialect_4" 100 8) 0 10139) "field statistics" 10240) 1) 1) "identifier" 103 2) "$.content" 104 3) "attribute" 105 4) "content" 106 5) "Index Errors" 107 6) 1) "indexing failures" 108 2) 0 109 3) "last indexing error" 110 4) "N/A" 111 5) "last indexing error key" 112 6) "N/A" 113 2) 1) "identifier" 114 2) "$.embedding" 115 3) "attribute" 116 4) "embedding" 117 5) "Index Errors" 118 6) 1) "indexing failures" 119 2) 0 120 3) "last indexing error" 121 4) "N/A" 122 5) "last indexing error key" 123 6) "N/A" 124 3) 1) "attribute" 125 2) "source" 126 3) "Index Errors" 127 4) 1) "last indexing error key" 128 2) "N/A" 129 3) "indexing failures" 130 4) 0 131 5) "last indexing error" 132 6) "N/A" 133 5) "identifier" 134 6) "$.source" 13541) "index_options" 13642) 13743) "num_docs" 13844) 5 13945) "tag_overhead_sz_mb" 14046) 0.00002765655517578125 14147) "Index Errors" 14248) 1) "background indexing status" 143 2) "OK" 144 3) "indexing failures" 145 4) 0 146 5) "last indexing error" 147 6) "N/A" 148 7) "last indexing error key" 149 8) "N/A" 15049) "index_definition" 15150) 1) "key_type" 152 2) "JSON" 153 3) "prefixes" 154 4) 1) "doc:" 155 5) "default_score" 156 6) 1 15751) "total_inverted_index_blocks" 15852) 2246 15953) "text_overhead_sz_mb" 16054) 0.006542205810546875 16155) "percent_indexed" 16256) 1 16357) "hash_indexing_failures" 16458) 0 16559) "total_indexing_time" 16660) 1.5779999494552612 16761) "indexing" 16862) 0 16963) "cursor_stats" 17064) 1) "global_idle" 171 2) 0 172 3) "global_total" 173 4) 0 174 5) "index_capacity" 175 6) 128 176 7) "index_total" 177 8) 0 17865) "max_doc_id" 17966) 5 18067) "vector_index_sz_mb" 18168) 4.216209411621094 182
数据存储结构:
1索引:spring-ai-knowledge 2├── 向量字段:embedding(1536维浮点数向量) 3├── 文本字段:content(原始文本) 4├── 元数据字段:source、chunk_index(可过滤) 5└── 相似度算法:余弦相似度(COSINE) 6
7. 本篇避坑指南
7.1. 坑1:普通Redis不支持向量检索
1错误:docker run -d redis 2正确:docker run -d redis/redis-stack 3
Redis Stack 才包含 RediSearch 模块。普通Redis启动后,向量存储会报 ERR unknown command 'FT.CREATE'。
7.2. 坑2:Embedding维度不匹配
通义千问Embedding是1536维,但代码里如果配置了其他模型(如1024维),向量写入和检索会因维度不一致失败。
解决:确认 spring.ai.openai.embedding.options.model 和实际用的模型一致。
7.3. 坑3:向量检索结果看似不相关
症状:搜"年假",第2、3名结果是"婚假"、"病假"。
原因:它们在向量空间里确实很近(都是休假相关段落)。这不是bug,是特征。
解决:后续用 Reranker 或混合检索提升精确度。
7.4. 坑4:Redis内存占用预估
每条向量存储约占用:1536 × 4字节(float32) ≈ 6KB。加上文本和元数据,每个文档块约10-20KB。
建议:万级文档块没问题,十万级以上考虑 Milvus。
7.5. 八、本篇小结
这一篇我们完成了RAG的第二步——向量化与存储:
| 步骤 | 工具 | 要点 |
|---|---|---|
| Embedding | EmbeddingModel | 文本→1536维向量 |
| 向量存储 | RedisVectorStore | 向量+文本+元数据 |
| 语义检索 | similaritySearch() | 用户问题→向量→找最近邻居 |
核心心法:
1Embedding的本质:把"语义相似"变成"坐标距离" 2向量的数据库的本质:用空间索引加速最近邻搜索 3 4当你把100个文档、1000个文档块都转成向量存入Redis后, 5你的AI就不再是"外人"——它能通过数学计算, 6在你的知识库里找到最相关的那句话。 7
现在向量数据库里已经有我们的私有知识了,能搜到最相关的文档块。
下一篇,我们要把这些检索到的文档块注入Prompt模板,让AI看着资料回答问题,跑通完整的RAG链路。
本文与DeepSeek协作完成
