第二步:文档知识库的雏形

127 阅读13分钟

在成功接入 Spring AI 和 DeepSeek, 实现基本对话功能后,我们开始尝试构建文档知识库。本章将重点介绍 RAG(检索增强生成) 的核心概念、文本向量化与存储的基本流程,并通过搭建 Redis-Stack 环境实现一个简单的文档知识库原型,为后续的智能问答功能打下基础。本文将提供清晰的步骤、代码示例(为方便查看代码,很多部分并没有做拆分、优化、抽象,需要时可自行优化)和环境配置,适合 Java 开发者快速上手。

image.png

一、什么是 RAG(检索增强生成)?

1. RAG 的定义

RAG(Retrieval-Augmented Generation)是一种结合 检索生成 的技术,通过从外部知识库检索相关信息,增强大语言模型(LLM)的回答能力。其核心思想是:

  • 检索:根据用户的问题,在知识库中找到最相关的文档或片段。
  • 生成:将检索到的内容作为上下文,输入大语言模型生成准确、自然的回答。

2. 为什么需要 RAG?

大语言模型虽然强大,但存在以下局限:

  • 知识截止:模型的知识受训练数据限制,无法实时获取最新信息。
  • 专业性不足:对于企业内部文档或特定领域的知识,模型可能无法提供精准答案。
  • 生成幻觉:模型可能生成不准确或虚构的内容。

RAG 通过引入外部知识库,解决了这些问题,确保回答基于真实的文档内容,提高准确性和可信度。

3. RAG 在本项目中的作用

在本项目中,RAG 将用于:

  • 存储企业或个人的文档(如 PDF、Word、Excel 等)。
  • 将文档向量化并存入向量数据库。
  • 根据用户问题检索相关文档,结合 DeepSeek 生成答案。

二、向量化及基本流程

1. 向量化

RAG 里,向量化(Embedding) 是最核心的一步。它的作用是把人类语言转换成计算机可以理解和比较的“数字向量”。

可以简单理解为:

  • 一句话,就像一句话的“坐标”,被映射到一个高维空间(例如 1536 维)。
  • 语义相近的句子,它们的向量坐标就会更靠近。
  • 这样,当我们想回答问题时,只要把问题也向量化,再去找“最靠近”的片段,就能检索到相关内容。

比如:

  • “我喜欢看电影” → [0.12, -0.33, ..., 0.87]
  • “我爱看影片” → [0.11, -0.31, ..., 0.86]
  • “今天天气很好” → [0.75, 0.12, ..., -0.44]

可以看到,前两句话表达的意思接近,所以它们的向量“距离”也很近,而第三句话就差得比较远。

这就是为什么向量化能帮我们解决“语义相似度”的问题,而不仅仅是做关键词匹配。

2. 基本流程

文本向量化是 RAG 的核心步骤,将非结构化文档转化为可检索的向量表示。以下是向量化的基本流程:

  1. 文本提取

    • 文档里通常有各种格式(PDF 中的排版、Word 的表格、Excel 的单元格),直接拿去向量化会失败。
    • 我们先把文档中的内容提取成纯文本(plain text),去掉多余的格式,只保留能被模型理解的文字。
    • 一份 PDF 简历提取后就变成“姓名:张三,技能:Java、Spring Boot,经历:某某公司…”。
  2. 文本切片(Chunking)

    • 向量模型通常对输入长度有限制(比如最多 8000 个 Token),如果直接把一整本书丢进去,肯定超限。
    • 把大文本切分成小段,比如每 200~500 字一片。这样既能保证每段语义完整,又方便后续检索。但是也要注意如果切分的太细就会增加成本而且精度丢失。
    • 一本说明书可以被切分为“第一章:安装步骤…”,“第二章:常见问题…”,“第三章:维护方法…”。
  3. 向量化(Embedding)

    • 机器无法直接理解文字的意思,但能理解数字。通过向量化,我们把每个文本片段转换成一个高维数字向量(如 1536 维,就是把这个片段转为了 1536 个数字去代替)。
    • 调用模型接口(如 OpenAI、阿里云 DashScope、DeepSeek 等)生成向量。语义相似的文本,它们的向量会比较接近。
    • 比如:“我喜欢看电影” 和 “我爱看影片” 会变成两个很接近的向量,而和 “今天天气很好” 的距离就远很多。
  4. 存储

    • 生成的向量和原始文本最好是保存起来,即便存储成本增加,但是在恢复的时候速度会比再向量化一次快,可以做持久化存储,方便后续检索,否则每次都要重新计算,效率太低。
    • 把向量 + 文本内容 + 相关元信息(如文档 ID、页码)存到向量数据库。这里我们选择 Redis-Stack,上手快,而且存储也是 json,易于理解,也支持向量检索,后续熟悉之后会替换为 milvus
    • 存储时会像这样保存:{docId: "001", chunk: "安装步骤…", embedding: [0.12, -0.33, …]}
  5. 检索

    • 用户提问时,AI 不能凭空知道答案,需要先找到相关资料。
    • 把用户的问题也向量化,然后去数据库里找“最相似”的片段(Top-K 结果),再把这些片段交给大模型生成最终答案。
    • 比如:用户问“怎么重置密码?”,系统会找到“常见问题”章节中的相关片段,再结合问题给出解答。

在本章中,我们将使用 Redis-Stack 作为向量数据库,完成上述流程的初步实现。

三、Redis-Stack 环境搭建

1. 为什么选择 Redis-Stack?

Redis-Stack 是一个扩展了 Redis 的开源数据库,支持向量搜索、JSON 存储等功能,适合快速原型开发。其优势包括:

  • 轻量级:易于部署,适合小型项目或开发测试,只是扩展了redis,所以不需要引入太多。
  • 向量搜索:支持高效的向量相似度检索(如余弦相似度)。
  • Spring 集成:Spring AI 提供了对 Redis-Stack 的原生支持。

2. 安装 Redis-Stack

方法 1:Docker 部署(推荐)

运行以下命令启动 Redis-Stack 容器:

docker run -d --name redis-stack -p 6379:6379 redis/redis-stack:latest
  • 默认端口:6379。

  • 验证安装:使用 redis-cli 连接 localhost:6379,运行 PING 命令,应返回 PONG。 连接也可以用redis-insight

3. 配置 Spring AI 与 Redis-Stack

在 pom.xml 中添加 Redis-Stack 的 Spring AI 依赖以及 openAI 依赖,因为 特性t-embedding-v要用阿里的apikey,是兼容openai的。阿里云百炼。处理 pdf 可引入依赖 pdfbox。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- 显式引入兼容 PDFBox -->
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>2.0.29</version>
</dependency>

在 application.yml 中配置 Redis 连接:

spring:
  ai:
    # OpenAI 兼容模式配置 阿里 文本向量化
    openai:
      api-key: sk-xxx
      base-url: https://dashscope.aliyuncs.com/compatible-mode
      embedding:
        api-key: sk-xxx
        base-url: https://dashscope.aliyuncs.com/compatible-mode
        options:
          model: text-embedding-v4
          dimensions: 1536
          encoding-format: float
          user: "yanyy"
    vectorstore:
      redis:
        prefix: "deepseek:"
        index-name: "deepseek-index"
  data:
    redis:
      host: 127.0.0.1
      port: 6378
      database: 0
      jedis:
        pool:
          max-idle: 8
          max-active: 16
          max-wait: 10000

在配置后,spring-ai 会自动生成对应的索引,在 redis-stack 中查看是否生成 deepseek-index 索引 客户端命令为:

FT._LIST

image.png

查看索引结构(内容比较多,就不截图了)

FT.INFO deepseek-index

查看索引里的文档数量

FT.SEARCH deepseek-index "*" LIMIT 0 0

image.png

四、实现文档知识库雏形

1. 文本提取与切片

为简单起见,我们先以纯文本文件为例,提取内容并进行切片。后续章节将引入 Tika 处理多格式文档。

代码示例:读取文本文件并切片

import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

@Component
public class DocumentProcessor {

    public List<String> processDocument(String filePath) throws IOException {
        try (PDDocument document = PDDocument.load(new File(filePath))) {
            PDFTextStripper stripper = new PDFTextStripper();
            String content = stripper.getText(document);

            // 简单切片:按固定长度(500 字符)切分
            List<String> chunks = new ArrayList<>();
            int chunkSize = 500;
            for (int i = 0; i < content.length(); i += chunkSize) {
                int end = Math.min(i + chunkSize, content.length());
                chunks.add(content.substring(i, end));
            }
            return chunks;
        }
    }
}

说明

  • 按 500 字符切片,实际项目中可根据语义或段落调整策略。
  • 后续章节将优化切片逻辑,考虑重叠和语义完整性。

2. 向量化与存储

使用阿里云 text-embedding-v4 将文本切片向量化,并存储到 Redis-Stack。这里我们还是自己配置 bean 配置类如下:

// ------------------------------ OpenAI API 配置 ---------------------------
@Value("${spring.ai.openai.base-url}")
private String openAiBaseUrl;
@Value("${spring.ai.openai.chat.options.model}")
private String openAiModelName;
@Value("${spring.ai.openai.api-key}")
private String openAiApiKey;

@Bean(name = "openAiApi")
public OpenAiApi openAiApi() {
    return OpenAiApi.builder()
            .apiKey(openAiApiKey)
            .baseUrl(openAiBaseUrl)
            .build();
}

// ------------------------------ textEmbeddingModel 配置 ---------------------------
@Value("${spring.ai.openai.embedding.options.model}")
private String textEmbeddingModelName;
@Value("${spring.ai.openai.embedding.options.dimensions}")
private int textEmbeddingModelDimensions;
@Value("${spring.ai.openai.embedding.options.encoding-format}")
private String textEmbeddingModelEncodingFormat;
@Value("${spring.ai.openai.embedding.options.user}")
private String textEmbeddingModelUser;

/**
 * 文本 Embedding 模型
 */
@Bean(name = "textEmbeddingModel")
public OpenAiEmbeddingModel textEmbeddingModel(@Qualifier("openAiApi") OpenAiApi openAiApi) {

    OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder()
            .model(textEmbeddingModelName)
            .dimensions(textEmbeddingModelDimensions)
            .encodingFormat(textEmbeddingModelEncodingFormat)
            .user(textEmbeddingModelUser)
            .build();

    return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, options);
}

@Bean
public JedisPooled jedisPooled(RedisConnectionDetails redisConnectionDetails) {
    // 包括主机、端口、用户名和密码,这些信息是连接Redis服务器所必需的
    return new JedisPooled(redisConnectionDetails.getStandalone().getHost(),
            redisConnectionDetails.getStandalone().getPort(),
            redisConnectionDetails.getUsername(),
            redisConnectionDetails.getPassword());
}

private MyRedisVectorStore createRedisVectorStore(
        JedisPooled jedisPooled,
        EmbeddingModel embeddingModel,
        RedisVectorStoreProperties properties
) {
    List<RedisVectorStore.MetadataField> metadataFields = List.of(
            RedisVectorStore.MetadataField.text("repositoryId"),
            RedisVectorStore.MetadataField.text("documentId"),
            RedisVectorStore.MetadataField.text("chunkId"),
            RedisVectorStore.MetadataField.text("available"),
            RedisVectorStore.MetadataField.text("type")
    );

    return MyRedisVectorStore.myBuilder(jedisPooled, embeddingModel)
            .indexName(properties.getIndexName())
            .prefix(properties.getPrefix())
            .embeddingFieldName("embedding")
            .contentFieldName("content")
            .batchingStrategy(new TokenCountBatchingStrategy())
            .metadataFields(metadataFields)
            .initializeSchema(true)
            .build();
}

private MyMilvusVectorStore createMilvusVectorStore(
        MilvusServiceClient milvusClient,
        EmbeddingModel embeddingModel,
        String collectionName,
        int embeddingDimension,
        String databaseName,
        Boolean autoId,
        Boolean initializeSchema
) {
    return MyMilvusVectorStore.myBuilder(milvusClient, embeddingModel)
            .initializeSchema(initializeSchema)
            .databaseName(databaseName)
            .collectionName(collectionName)
            .embeddingDimension(embeddingDimension)
            .autoId(autoId)
            .build();
}

// ---------------- Text ----------------
@Bean(name = "myTextRedisVectorStore")
public MyRedisVectorStore myTextRedisVectorStore(
        JedisPooled jedisPooled,
        @Qualifier("textEmbeddingModel") EmbeddingModel embeddingModel,
        RedisVectorStoreProperties properties) {
    return createRedisVectorStore(jedisPooled, embeddingModel, properties);
}

其中 MyRedisVectorStore 是为了后续扩展 spring-ai 的 RedisVectorStore,rehandle 方法可以直接将持久化后的 embedding 向量恢复到 redis 中。这是为了减少成本,官方的 add 方法会在调用的时候再重新生成一次向量,在实际应用大多时候是增量或者恢复,所以扩展该方法。

public class MyRedisVectorStore extends RedisVectorStore {

    private static final String embeddingFieldName = "embedding";
    private static final String contentFieldName = "content";
    private static final Path2 JSON_SET_PATH = Path2.of("$");
    private static final Predicate<Object> RESPONSE_OK = Predicate.isEqual("OK");
    private static final Logger logger = LoggerFactory.getLogger(MyRedisVectorStore.class);
    private static final String prefix = "deepseek:";

    protected MyRedisVectorStore(RedisVectorStore.Builder builder) {
        super(builder);
    }

    /**
     * 自定义处理逻辑:补充 metadata 或过滤无效文档
     */
    public void rehandle(List<Document> oDocuments) {
        List<Document> documents = new ArrayList<>(oDocuments);
        try (Pipeline pipeline = this.getJedis().pipelined()) {
            for (Document document : documents) {
                var fields = new HashMap<String, Object>();

                // ---- 处理 embedding ----
                Object embeddingObj = document.getMetadata().remove("embedding");
                if (embeddingObj == null) {
                    throw new UsrReqErr("文档 " + document.getId() + " 没有 embedding 数据");
                }

                byte[] embeddingBlob;
                if (embeddingObj instanceof List<?> list) {
                    logger.info("文档 {} 的 embedding 是 List 类型,长度: {}", document.getId(), list.size());
                    embeddingBlob = toFloat32Blob(list);
                } else if (embeddingObj instanceof float[] arr) {
                    logger.info("文档 {} 的 embedding 是 float[] 类型,长度: {}", document.getId(), arr.length);
                    embeddingBlob = toFloat32Blob(arr);
                } else if (embeddingObj instanceof double[] arr) {
                    logger.info("文档 {} 的 embedding 是 double[] 类型,长度: {}", document.getId(), arr.length);
                    embeddingBlob = toFloat32Blob(arr);
                } else {
                    logger.error("文档 {} 的 embedding 格式不支持: {}", document.getId(), embeddingObj.getClass());
                    throw new UsrReqErr("文档 " + document.getId() + " 的 embedding 格式不支持: " + embeddingObj.getClass());
                }

                fields.put(embeddingFieldName, embeddingBlob);

                // ---- 写入内容 ----
                fields.put(contentFieldName, Optional.ofNullable(document.getText()).orElse(""));

                // ---- 写入元数据 ----
                fields.putAll(document.getMetadata());

                pipeline.jsonSetWithEscape(key(document.getId()), JSON_SET_PATH, fields);
            }

            List<Object> responses = pipeline.syncAndReturnAll();
            Optional<Object> errResponse = responses.stream().filter(Predicate.not(RESPONSE_OK)).findAny();
            if (errResponse.isPresent()) {
                String message = MessageFormat.format("Could not rehandleMultimodal document: {0}", errResponse.get());
                if (logger.isErrorEnabled()) {
                    logger.error(message);
                }
                throw new UsrReqErr(message);
            }
        }
    }

    /**
     * 将 List<?> 转换成 float32 小端字节数组
     */
    private byte[] toFloat32Blob(List<?> list) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(4 * list.size()).order(ByteOrder.LITTLE_ENDIAN);
        for (Object obj : list) {
            float v = ((Number) obj).floatValue();
            byteBuffer.putFloat(v);
        }
        return byteBuffer.array();
    }

    /**
     * 将 float[] 转换成 float32 小端字节数组
     */
    private byte[] toFloat32Blob(float[] arr) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(4 * arr.length).order(ByteOrder.LITTLE_ENDIAN);
        for (float v : arr) {
            byteBuffer.putFloat(v);
        }
        return byteBuffer.array();
    }

    /**
     * 将 double[] 转换成 float32 小端字节数组
     */
    private byte[] toFloat32Blob(double[] arr) {
        ByteBuffer byteBuffer = ByteBuffer.allocate(4 * arr.length).order(ByteOrder.LITTLE_ENDIAN);
        for (double v : arr) {
            byteBuffer.putFloat((float) v);
        }
        return byteBuffer.array();
    }


    private String key(String id) {
        return prefix + id;
    }
    /**
     * 自定义 Builder,使用组合方式复用父类 Builder
     */
    public static class MyBuilder {

        private final RedisVectorStore.Builder parentBuilder;

        public MyBuilder(JedisPooled jedis, EmbeddingModel embeddingModel) {
            this.parentBuilder = RedisVectorStore.builder(jedis, embeddingModel);
        }

        public MyBuilder indexName(String indexName) {
            this.parentBuilder.indexName(indexName);
            return this;
        }

        public MyBuilder prefix(String prefix) {
            this.parentBuilder.prefix(prefix);
            return this;
        }

        public MyBuilder embeddingFieldName(String embeddingFieldName) {
            this.parentBuilder.embeddingFieldName(embeddingFieldName);
            return this;
        }

        public MyBuilder contentFieldName(String contentFieldName) {
            this.parentBuilder.contentFieldName(contentFieldName);
            return this;
        }

        public MyBuilder initializeSchema(boolean initializeSchema) {
            this.parentBuilder.initializeSchema(initializeSchema);
            return this;
        }

        public MyBuilder batchingStrategy(BatchingStrategy batchingStrategy) {
            this.parentBuilder.batchingStrategy(batchingStrategy);
            return this;
        }

        public MyBuilder metadataFields(List<RedisVectorStore.MetadataField> metadataFields) {
            this.parentBuilder.metadataFields(metadataFields);
            return this;
        }

        public MyRedisVectorStore build() {
            return new MyRedisVectorStore(this.parentBuilder);
        }
    }

    // 提供静态工厂方法
    public static MyBuilder myBuilder(JedisPooled jedis, EmbeddingModel embeddingModel) {
        return new MyBuilder(jedis, embeddingModel);
    }
}

代码示例:向量化并存储,因为向量化的时候一次最多接收10个切片,所以在处理切片的时候10个为一组去embedding 然后添加到 redis-stack 中

private final ChatClient chatClient;

private final MyRedisVectorStore myTextRedisVectorStore;

public RagDocumentController(@Qualifier("deepseekV3ClientNoMemory") ChatClient chatClient,
                             @Qualifier("myTextRedisVectorStore") MyRedisVectorStore myTextRedisVectorStore) {
    this.chatClient = chatClient;
    this.myTextRedisVectorStore = myTextRedisVectorStore;
}

private static final String TEST_DOCUMENT = "D:\mydownload\测试文档\测试.pdf";

@PostMapping(value = "/chat/document-chunk-embedding", produces = "application/json")
public Result<Integer> testBuiltChunk() {
    try {
        List<String> chunkList = this.processDocument(TEST_DOCUMENT);
        List<org.springframework.ai.document.Document> documentList = new ArrayList<>();
        for (String chunk : chunkList) {
            org.springframework.ai.document.Document document = new Document(chunk);
            documentList.add(document);
        }
        if (documentList.size() >= 10) {
            myTextRedisVectorStore.add(documentList);
            documentList.clear();
        }
        if (!documentList.isEmpty()) {
            myTextRedisVectorStore.add(documentList);
        }
        return Result.success(chunkList.size());
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

public List<String> processDocument(String filePath) throws IOException {
    try (PDDocument document = PDDocument.load(new File(filePath))) {
        PDFTextStripper stripper = new PDFTextStripper();
        String content = stripper.getText(document);

        // 简单切片:按固定长度(500 字符)切分
        List<String> chunks = new ArrayList<>();
        int chunkSize = 500;
        for (int i = 0; i < content.length(); i += chunkSize) {
            int end = Math.min(i + chunkSize, content.length());
            chunks.add(content.substring(i, end));
        }
        return chunks;
    }
}

说明

  • MyRedisVectorStore:将向量和文本内容存储到 Redis-Stack,并自动创建索引。
  • org.springframework.ai.document.Document:Spring AI 提供的文档对象,包含文本和嵌入向量。 运行后可查看,以下为截图

d5da64ad-c80e-41ff-978c-8e1f70d1d413.png

3. 简单检索测试

实现一个简单的检索功能,验证知识库是否正常工作。

spring-ai 已经有通用的 org.springframework.ai.vectorstore.SearchRequest,对于 redis-stack 的简单检索,具体实现可以看源码的 similaritySearch() 方法,实现了对用户输入的内容进行 embedding,然后去检索,还可以添加一些具体的查询条件(在初始化的时候要设置哪些字段带索引,此处只是简单示例,后续会有字段索引,可以实现权限控制之类的), 以下是一个示例

代码示例:检索文档

@PostMapping("/chat/document-search")
public Result<List<String>> search(@RequestParam String query) {
    SearchRequest request = SearchRequest.builder()
            .query(query)
            .topK(3)
            .build();
    final List<Document> documents = myTextRedisVectorStore.similaritySearch(request);
    List<String> result = new ArrayList<>();
    for (Document document : documents) {
        result.add(document.getText());
    }
    return Result.success(result);
}

测试

  1. 调用 addDocument 方法,上传一个文本文件(如包含“AI 知识库简介”的文档)。
  2. 访问 http://localhost:8080/search?query=什么是 AI 知识库,返回最相关的 3 个文本片段。

apifox 调试

image.png

此时索引到的内容就可以设置为deepseek 的上下文,他可以根据索引到的内容进行回答,而不是胡编乱造,这样就是一个简易的 rag,后续的优化方向就是以下这几处

五、常见问题与优化

文档切片优化

支持多格式,比如doc,pdf,excel,ppt,md等,大致分为 文本类,表格类,ppt。

  • 文本类主要是切片大小,重叠大小。还有对于其中的图片怎么处理,一般是 ocr(本地或者使用阿里云,阿里云最好还是把其他格式转为 pdf 文档,效果比较好),可以获取图片内容,确保文档的内容尽量完善。

  • 对于 excel,要考虑有是否为多 sheet,是一行数据一个切片还是多行,每个切片是否保留表头(保留表头虽然成本高一点,但是检索效果更好)。

  • ppt就直接按幻灯片来处理,注意的点还是对于其中图片的处理。

向量化及存储

  • 向量化目前来看,在切片大小合适的时候,还是尽量高维度。国内比较成熟的有豆包和阿里可以选用。
  • 对于存储,量级不大redis-stack完全够用,如果要考虑后续扩展,业务量上升,还是直接弄 milvus,社区比较完善。
  • 目前官方只设置了两个字段 content(文本) embedding(向量), 可以在这里扩展。比如你的层级是知识库(repository) -> 知识库文档(repository_document),那么可以添加字段 repository_id 或者 repository_document_id,给字段建立索引,后续查询时就可以直接用。针对 redis-stack
private MyRedisVectorStore createRedisVectorStore(
        JedisPooled jedisPooled,
        EmbeddingModel embeddingModel,
        RedisVectorStoreProperties properties
) {
    List<RedisVectorStore.MetadataField> metadataFields = List.of(
            RedisVectorStore.MetadataField.text("repositoryId"),
            RedisVectorStore.MetadataField.text("documentId"),
            RedisVectorStore.MetadataField.text("chunkId"),
            RedisVectorStore.MetadataField.text("available"),
            RedisVectorStore.MetadataField.text("type")
    );

    return MyRedisVectorStore.myBuilder(jedisPooled, embeddingModel)
            .indexName(properties.getIndexName())
            .prefix(properties.getPrefix())
            .embeddingFieldName("embedding")
            .contentFieldName("content")
            .batchingStrategy(new TokenCountBatchingStrategy())
            .metadataFields(metadataFields)
            .initializeSchema(true)
            .build();
}

该代码前文已有,其中 repositoryId 等已经建立了索引,在向量化时可以设置对应值:

@PostMapping(value = "/chat/document-chunk-embedding", produces = "application/json")
public Result<Integer> testBuiltChunk() {
    try {
        List<String> chunkList = this.processDocument(TEST_DOCUMENT);
        List<org.springframework.ai.document.Document> documentList = new ArrayList<>();
        for (String chunk : chunkList) {
            org.springframework.ai.document.Document document = new Document(chunk);
            document.getMetadata().put("repositoryId", "1");
            document.getMetadata().put("documentId", "test-doc-001");
            document.getMetadata().put("available", "1");
            documentList.add(document);
            if (documentList.size() >= 10) {
                myTextRedisVectorStore.add(documentList);
                documentList.clear();
            }
        }
        if (!documentList.isEmpty()) {
            myTextRedisVectorStore.add(documentList);
        }
        return Result.success(chunkList.size());
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

使用为

@PostMapping(value = "/chat/document-search", produces = "application/json")
public Result<List<String>> search(@RequestParam String query) {
    FilterExpressionBuilder builder = new FilterExpressionBuilder();
    String[] repoIdArray = new String[]{"1", "2"};
    List<Object> values = List.of(repoIdArray);
    org.springframework.ai.vectorstore.filter.Filter.Expression filter = builder.and(
                    builder.in("repositoryId", values),
                    builder.in("available", "1"))
            .build();
    SearchRequest request = SearchRequest.builder()
            .query(query)
            .topK(3)
            .filterExpression(filter)
            .build();
    final List<Document> documents = myTextRedisVectorStore.similaritySearch(request);
    List<String> result = new ArrayList<>();
    for (Document document : documents) {
        result.add(document.getText());
    }
    return Result.success(result);
}

添加 filter 就可以,需要注意的是在初始化如果是 String,那么在查询时用 int 就会失效,必须转为 String 再去构造条件。

持久化存储,可以把整个 Document 都存入持久化数据库中,包括 content,embedding,以及你需要的内容,整体就是一个 json。

检索

  • Prompt

对于用户输入的内容,可以添加一个 Prompt 优化构造器,扩充完善用户的问题,这里涉及到对于前置对话的存储,即给 chatClient 记忆,官方已实现该功能,后续会给出示例

  • 检索

可以将切片后的metadata存入 elasticsearch,使用 bm25 关键词检索,可以和向量检索并行,也可以先关键词检索再向量检索,都可以提高准确率

六、总结与下一步

通过本章,我们搭建了一个文档知识库的雏形,完成了:

  • RAG 技术的基本理解。
  • Redis-Stack 环境的搭建与配置。
  • 文本切片、向量化、存储与检索的初步实现。

虽然当前知识库仅支持简单文本和检索,但已为后续功能奠定了基础。之后将引入 Tika 解析多格式文档(PDF、Word、Excel 等),并处理复杂 PDF 的 OCR 需求,进一步丰富知识库的内容来源。