第13篇:《把PDF变成AI能懂的"密码":我用向量数据库建了个知识库》

0 阅读9分钟

承上:上一篇我们把文档切成了高质量的小碎片,但它们还只是文本。AI不认识文本,只认识数字。今天,我们要把这些文本碎片变成一串串数字——向量,存入向量数据库,让AI真正能"理解"你的私有知识。

1. 先搞懂:什么是Embedding?

1.1. 用后端老鸟的类比

假设你是数据库管理员,要给1000本书建立索引:

传统索引:
"Java编程思想" → 按书名倒排 → J字母开头 → 第3排第5本

向量索引:
"Java编程思想" → 转成一串数字[0.23, -0.15, 0.78, ...] → 
  在1536维空间里找一个坐标点 → 
  查"Java入门"时 → 也转成坐标 → 找最近的点 → 就是这本书!

Embedding 就是把文本映射到高维空间的坐标点。语义越相似,坐标越接近。

"年假怎么请" → [0.12, -0.34, 0.56, ...]  ← 1536个数字
"休假政策"   → [0.13, -0.32, 0.54, ...]  ← 坐标非常接近!
"今天天气"   → [-0.78, 0.45, -0.23, ...] ← 坐标很远

Spring AI 2.0 通过统一的 EmbeddingModel 接口支持多种模型:

模型维度最大输入价格
text-embedding-ada-002(OpenAI)15368191 Token¥0.008/千Token
text-embedding-3-small(OpenAI)15368191 Token¥0.002/千Token
通义千问 Embedding15362048 Token¥0.0007/千Token

本篇使用通义千问的Embedding模型,和我们的对话模型一致,国内访问稳定,价格便宜。

2. 第一步:配置Embedding模型

2.1. 添加依赖

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-openai</artifactId>
</dependency>

注意:和之前的对话模型用同一个Starter。通义千问的Embedding也通过OpenAI兼容接口提供。

2.2. 配置Embedding

spring:
  ai:
    openai:
      api-key: xxxx
      base-url: https://ws-xxxx.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
      chat:
        options:
          model: qwen3.7-max
      embedding:
        options:
          model: qwen3.7-text-embedding

2.3. 第一个Embedding测试

package com.oldbird.ai.chapter13.embedding;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class EmbeddingDemo {

    private final EmbeddingModel embeddingModel;

    public EmbeddingDemo(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public void testEmbedding() {
        // 将文本转成向量
        EmbeddingResponse response = embeddingModel.embedForResponse(
                List.of("入职满1年享有5天带薪年假", "年假怎么申请")
        );

        List<float[]> vectors = response.getResults().stream()
                .map(result -> result.getOutput())
                .toList();

        System.out.println("向量维度:" + vectors.get(0).length);  // 1536
        System.out.println("第一个向量前5个值:" +
                vectors.get(0)[0] + ", " +
                vectors.get(0)[1] + ", " +
                vectors.get(0)[2] + ", " +
                vectors.get(0)[3] + ", " +
                vectors.get(0)[4]);

        // 计算相似度
        double similarity = cosineSimilarity(vectors.get(0), vectors.get(1));
        System.out.println("两句话的余弦相似度:" + similarity);
        // 输出:0.92(高度相似)
    }

    /**
     * 余弦相似度:衡量两个向量在多维空间中的夹角
     * 1.0 = 完全同向(语义相同)
     * 0.0 = 正交(无关)
     * -1.0 = 完全反向(语义相反)
     */
    private double cosineSimilarity(float[] a, float[] b) {
        double dotProduct = 0;
        double normA = 0;
        double normB = 0;
        for (int i = 0; i < a.length; i++) {
            dotProduct += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}

运行结果

向量维度:1024
第一个向量前5个值:0.023570376, -0.009924047, -0.025762115, 0.0029585415, 0.02347242
两句话的余弦相似度:0.643651355275984

关键认知:文本→1024个浮点数→一个坐标。从此以后,"语义相似"可以用"坐标距离"来数学计算。这就是AI能"理解"文本的底层原理。

3. 第二步:选择向量数据库

3.1. 主流向量的数据库对比

向量数据的选型我没有经验可谈哈,但是用于学习实战和公司小型知识库我觉得redis足够。

数据库类型优势劣势适用场景
Redis Stack内存+磁盘已有Redis可复用,毫秒级内存贵,大规模成本高中小规模、已有Redis
Milvus专业向量库十亿级向量,索引丰富独立部署,运维复杂大规模生产
PGVectorPostgreSQL插件和业务库一体,SQL查询百万级以上性能下降已有PostgreSQL
Elasticsearch搜索引擎混合检索(关键词+向量)向量检索非原生已有ES

3.2. 本篇选择:Redis Stack

理由

  1. 上一篇第9篇(ChatMemory)已经用了Redis,不引入新中间件
  2. 开发阶段零成本,Docker一行命令搞定
  3. 向量检索性能足够(百万级以下)

3.3. 部署Redis Stack

docker run -d \
  --name redis-stack \
  -p 6379:6379 \
  -p 8001:8001 \
  redis/redis-stack:latest

Redis Stack 包含了 RediSearch 模块,支持向量检索。普通Redis不行。

4. 第三步:向量化并写入Redis

4.1. 添加依赖

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-redis</artifactId>
</dependency>

4.2. 配置向量存储Bean

package com.yunxi.ai.config;

import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.redis.RedisVectorStore;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.RedisClient;

@Configuration
public class VectorStoreConfig {
    @Bean
    public RedisVectorStore vectorStore(RedisClient jedisRedisClient, EmbeddingModel embeddingModel) {
        return RedisVectorStore.builder(jedisRedisClient, embeddingModel)
                .indexName("spring-ai-knowledge")
                .prefix("doc:")
                .metadataFields(
                        RedisVectorStore.MetadataField.tag("source")
                )
                .initializeSchema(true)
                .build();
    }
}

4.3. 完整的导入服务

package com.yunxi.ai.controller;

import com.yunxi.ai.entity.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequestMapping("/rag")
@Slf4j
public class RagController {

    /**
     * 导入文档到向量数据库
     * 完整流程:读取 → 分割 → 向量化 → 存储
     */
    @RequestMapping("/uploadAndEmbedding")
    public Result uploadAndEmbedding(@RequestParam("file") MultipartFile file, @RequestParam(value = "chunkSize", defaultValue = "500") Integer chunkSize) {
        if (file == null || file.isEmpty()) {
            return Result.failed(500, "文件为空");
        }
        try {
            log.info("开始处理文件:{},分片大小:{}", file.getOriginalFilename(), chunkSize);
            Resource resource = file.getResource();
            TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(resource);
            List<Document> read = tikaDocumentReader.read();
            log.info("文件解析完成,原始文档数:{}", read.size());
            TokenTextSplitter splitter = TokenTextSplitter.builder().withChunkSize(chunkSize).build();
            List<Document> apply = splitter.apply(read);
            log.info("分片完成,总分片数:{}", apply.size());
            // 向量化存储
            vectorStore.add(apply);
            return Result.successed(apply);
        } catch (Exception e) {
            log.error("文件处理异常", e);
            return Result.failed(500, "文件处理异常:" + e.getMessage());
        }
    }

}

核心代码就一句:vectorStore.add(apply);

4.4. 验证导入

生成了10个分片,Redis里发生了什么?

Key: doc:4dd6f53b-bd60-4327-b566-131247fcf9ee
Type: Hash
Value: {
  "chunk_index": 4,
  "parent_document_id": "5f0a7d4a-b53f-4cc6-b5fd-8b6e5541120c",
  "embedding": [
    0.01784777,
    0.014171031,
    -0.020203378,
    -0.052795503...
  ],
  "source": "1001.docx",
  "total_chunks": 10,
  "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."
}

每个文档块都存了:原始文本 + 元数据 + 向量

5. 第四步:向量检索

5.1. 检索逻辑

    /**
     * 向量检索
     */
    @RequestMapping("/searchEmbedding")
    public Result searchEmbedding(String query, Integer topK) {
        try{
            SearchRequest request = SearchRequest.builder()
                    .query(query)
                    .topK(topK)
                    .build();
            List<Document> results = vectorStore.similaritySearch(request);
            return Result.successed(results);
        } catch (Exception e) {
            return Result.failed(500, "系统异常");
        }
    }

5.2. 测试检索效果

查询1:年假相关

分析:第一个结果精准命中"年假"内容。第二个和第三个虽然也是休假相关,但和年假不完全匹配——这就是向量检索的特点:找语义相似的,不一定是关键词完全匹配的。

查询2:长期超过50天

用户问的是"出差超过50天",AI精准检索到了"长期出差"那段,长期出差超过30天。用户可能用了口语化表达,但向量检索理解了他的语义。

6. 向量数据库里到底存了什么?

用Redis CLI看一下:

# 查看索引信息
redis-cli FT.INFO spring-ai-knowledge
FT.INFO spring-ai-knowledge
1) "key_table_size_mb"
2) 0.0001659393310546875
3) "geoshapes_sz_mb"
4) 0
5) "bytes_per_record_avg"
6) 89.2532730102539
7) "index_name"
8) "spring-ai-knowledge"
9) "attributes"
10)     1)      1) "identifier"
        2) "$.content"
        3) "attribute"
        4) "content"
        5) "type"
        6) "TEXT"
        7) "WEIGHT"
        8) 1
        9) "flags"
        10) 
        2)      1) "algorithm"
        2) "HNSW"
        3) "distance_metric"
        4) "COSINE"
        5) "flags"
        6) 
        7) "identifier"
        8) "$.embedding"
        9) "data_type"
        10) "FLOAT32"
        11) "dim"
        12) 1024
        13) "M"
        14) 16
        15) "ef_construction"
        16) 200
        17) "attribute"
        18) "embedding"
        19) "type"
        20) "VECTOR"
        3)      1) "identifier"
        2) "$.source"
        3) "attribute"
        4) "source"
        5) "type"
        6) "TAG"
        7) "SEPARATOR"
        8) ""
        9) "flags"
        10) 
11) "sortable_values_size_mb"
12) 0
13) "offset_bits_per_record_avg"
14) 8
15) "number_of_uses"
16) 1
17) "num_records"
18) 229
19) "total_index_memory_sz_mb"
20) 0.043750762939453125
21) "cleaning"
22) 0
23) "inverted_sz_mb"
24) 0.019492149353027344
25) "gc_stats"
26)     1) "bytes_collected"
        2) 0
        3) "total_ms_run"
        4) 0
        5) "total_cycles"
        6) 0
        7) "average_cycle_time_ms"
        8) NaN
        9) "last_run_time_ms"
        10) 0
        11) "gc_numeric_trees_missed"
        12) 0
        13) "gc_blocks_denied"
        14) 0
27) "num_terms"
28) 196
29) "offset_vectors_sz_mb"
30) 0.000385284423828125
31) "doc_table_size_mb"
32) 0.015834808349609375
33) "records_per_doc_avg"
34) 45.79999923706055
35) "offsets_per_term_avg"
36) 1.7641921043395996
37) "dialect_stats"
38)     1) "dialect_1"
        2) 0
        3) "dialect_2"
        4) 0
        5) "dialect_3"
        6) 0
        7) "dialect_4"
        8) 0
39) "field statistics"
40)     1)      1) "identifier"
        2) "$.content"
        3) "attribute"
        4) "content"
        5) "Index Errors"
        6)      1) "indexing failures"
        2) 0
        3) "last indexing error"
        4) "N/A"
        5) "last indexing error key"
        6) "N/A"
        2)      1) "identifier"
        2) "$.embedding"
        3) "attribute"
        4) "embedding"
        5) "Index Errors"
        6)      1) "indexing failures"
        2) 0
        3) "last indexing error"
        4) "N/A"
        5) "last indexing error key"
        6) "N/A"
        3)      1) "attribute"
        2) "source"
        3) "Index Errors"
        4)      1) "last indexing error key"
        2) "N/A"
        3) "indexing failures"
        4) 0
        5) "last indexing error"
        6) "N/A"
        5) "identifier"
        6) "$.source"
41) "index_options"
42) 
43) "num_docs"
44) 5
45) "tag_overhead_sz_mb"
46) 0.00002765655517578125
47) "Index Errors"
48)     1) "background indexing status"
        2) "OK"
        3) "indexing failures"
        4) 0
        5) "last indexing error"
        6) "N/A"
        7) "last indexing error key"
        8) "N/A"
49) "index_definition"
50)     1) "key_type"
        2) "JSON"
        3) "prefixes"
        4)      1) "doc:"
        5) "default_score"
        6) 1
51) "total_inverted_index_blocks"
52) 2246
53) "text_overhead_sz_mb"
54) 0.006542205810546875
55) "percent_indexed"
56) 1
57) "hash_indexing_failures"
58) 0
59) "total_indexing_time"
60) 1.5779999494552612
61) "indexing"
62) 0
63) "cursor_stats"
64)     1) "global_idle"
        2) 0
        3) "global_total"
        4) 0
        5) "index_capacity"
        6) 128
        7) "index_total"
        8) 0
65) "max_doc_id"
66) 5
67) "vector_index_sz_mb"
68) 4.216209411621094

数据存储结构

索引:spring-ai-knowledge
├── 向量字段:embedding(1536维浮点数向量)
├── 文本字段:content(原始文本)
├── 元数据字段:source、chunk_index(可过滤)
└── 相似度算法:余弦相似度(COSINE)

7. 本篇避坑指南

7.1. 坑1:普通Redis不支持向量检索

错误:docker run -d redis
正确:docker run -d redis/redis-stack

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的第二步——向量化与存储:

步骤工具要点
EmbeddingEmbeddingModel文本→1536维向量
向量存储RedisVectorStore向量+文本+元数据
语义检索similaritySearch()用户问题→向量→找最近邻居

核心心法

Embedding的本质:把"语义相似"变成"坐标距离"
向量的数据库的本质:用空间索引加速最近邻搜索

当你把100个文档、1000个文档块都转成向量存入Redis后,
你的AI就不再是"外人"——它能通过数学计算,
在你的知识库里找到最相关的那句话。

现在向量数据库里已经有我们的私有知识了,能搜到最相关的文档块。

下一篇,我们要把这些检索到的文档块注入Prompt模板,让AI看着资料回答问题,跑通完整的RAG链路。


本文与DeepSeek协作完成