Spring with AI (4): 搜索扩展——向量数据库与RAG(上)

本文代码:
https://github.com/JunTeamCom/ai-demo/tree/release-4.0
https://github.com/JunTeamCom/ai-demo-tools/tree/release-4.0/data-loader

本章只讲解RAG整体流程、向量数据库、数据写入;

查询向量数据库、RAG应用在下一讲。

Spring with AI系列,只关注上层AI的应用程序(基于JAVA搭建),不关注底层的LLM原理、搭建等技术。

RAG能通过实时搜索数据库的方式,扩展已经训练固化的大模型的知识能力。

RAG(R etrieval-A ugmented G eneration)检索增强生成

retrieval /rɪˈtriːvl/ n.检索

augmented /ɔ:g'mentɪd/ adj.增强的

generation /ˌdʒenəˈreɪʃn/ n.生成

通过检索、增强大模型生成的内容。

在前文中,我们通过文本模板,补充了"规则",进而增强了大模型生成的内容。

然而这只是简单的"文本匹配+配置文件"的方式,面对复杂问题和庞大的知识库,完全不具备现实意义。

RAG整体的流程和组成部分如下:
flowchart LR subgraph VectorDB[向量数据库模块] VectorStore[(向量数据库)]:::db end subgraph DocumentLoader[文档加载模块] OriginalDocs[① 原始文档] --> Splitter[文档分割器]:::process Splitter --> DocChunks[② 文档分块] DocChunks --> CalcEmb[③ 计算向量嵌入]:::process CalcEmb -->|存入| VectorStore end subgraph RAGApp[支持RAG的应用] Question[问题] --> App[④ 应用]:::app App -->|搜索相似文档| VectorStore VectorStore -->|返回相似文档| App App --> Prompt[⑤ 组装提示词] Prompt --> LLM[大语言模型] LLM --> Answer[答案] Answer --> App App --> Output[输出答案] end classDef process fill:#a5d6a7,stroke:#388e3c,stroke-width:2px classDef app fill:#ffab91,stroke:#e64a19,stroke-width:2px classDef db fill:#bbdefb,stroke:#1976d2,stroke-width:2px classDef llm fill:#ffcc80,stroke:#f57c00,stroke-width:2px

1 引言

自然语言,是怎么变成向量、并且用于检索的呢?

将句子拆分为最小语义单元(token),再通过词汇表为每个token分配唯一ID;这样自然语言就编码变成了向量。例如:["我", "爱", "北京"] → [3, 54, 65]。再进行编码、降维等处理,自然语言就变成了向量。

如何检索呢?本质上是计算问题与答案直接的相关性,也就是"距离";而这个距离,是通过向量的"余弦相似度"测算的,计算两个向量夹角的余弦值来衡量它们相似度的一种方法,值越接近1表示越相似,越接近-1表示越不相似。这种算法的优点是:

不受向量长度影响:只关注方向一致性,适合不同长度的文本或特征向量。

计算复杂度低:尤其适合稀疏向量,只需考虑非零分量。

2 安装向量数据库

本文以Qdrant(功能全部署相对简单)为例。当然还有Milvus(阿里云向量数据库基础)、ChromaDB(Python原生支持)、FAISS(Meta开源产品)等选择,没有本质的差别。

比较完整的一个向量数据库列表:

  • Apache Cassandra
  • Chroma
  • Elasticsearch
  • GemFire
  • SAP Hana
  • Milvus
  • MongoDB
  • Neo4j
  • Pinecone
  • PostgreSQL with pgvector extension
  • Qdrant
  • Redis with RediSearch module
  • Weaviate
  • FAISS

可以看到,一些列存储数据库/KV数据库/文档数据库/图数据库也在列表。

Qdrant安装还是相对复杂的,所以一般是推荐开箱即用的Docker方式;不过考虑到性能、还有存储目录等因素,本文才用了原生安装方式:
https://github.com/qdrant/qdrant/releases

Windows一般选择qdrant-x86_64-pc-windows-msvc.zip版本;比如解压到E:\Softwares\Qdrant

powershell 复制代码
mkdir E:\Softwares\Qdrant

然后创建几个子目录:

powershell 复制代码
mkdir E:\Softwares\Qdrant\storage
mkdir E:\Softwares\Qdrant\config
mkdir E:\Softwares\Qdrant\static
notepad E:\Softwares\Qdrant\config\config.yaml

然后编辑配置文件:

yaml 复制代码
service:
  host: 0.0.0.0
  http_port: 6333
  grpc_port: 6334

storage:
  storage_path: "./storage"

然后下载Web客户端:
https://github.com/qdrant/qdrant-web-ui/releases

将包内的文件,解压到static文件夹:

powershell 复制代码
    Directory: E:\Softwares\Qdrant\static

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----           2026/2/19    17:35                assets
-a---           2026/2/19    17:35          15086 favicon.ico
-a---           2026/2/19    17:35           1790 index.html
-a---           2026/2/19    17:35           6371 logo-red-black.svg
-a---           2026/2/19    17:35           6359 logo-red-white.svg
-a---           2026/2/19    17:35           9339 logo.png
-a---           2026/2/19    17:35           6177 logo192.png
-a---           2026/2/19    17:35          23528 logo512.png
-a---           2026/2/19    17:35            484 manifest.json
-a---           2026/2/19    17:35         436518 openapi.json
-a---           2026/2/19    17:35         834374 qdrant-web-ui.spdx.json
-a---           2026/2/19    17:35             67 robots.txt

启动Qdrant

powershell 复制代码
cd E:\Softwares\Qdrant\
\.Qdrant.exe

日志里显示:

复制代码
Version: 1.17.0, build: 4ab6d2ee
Access web UI at http://localhost:6333/dashboard

打开链接即可。

在Tutorial - Quick Start菜单,在第一页一路Run、即可创建一个Collection(类似MongoDB的Collection,可以认为是个表;这一步可以不做,只是为了理解向量数据库)

然后在右上角🔑菜单点入,设置API Key;然后环境变量添加QDRANT_API_KEY;然后重启IDE

3 JAVA工程添加配置

需要新建一个data-loader的JAVA工程,实现数据导入;这样方便权限隔离。

本文为了简单化,后续所有工具都放到一个Git项目里。

先建一个Github项目:

JAVA工程除了使用Starter,也可以使用IDE生成SpringBoot基础项目、再手动添加依赖(如上图所示,以VSCode为例):

  1. starter(通过Add Spring Boot Starters添加)
  • spring-boot-starter-web(通过Web搜索)
  • spring-ai-starter-model-openai(通过OpenAI搜索)
  • spring-ai-starter-vector-store-qdrant(通过Qdrant搜索)
  • spring-ai-tika-document-reader(通过Tika搜索)
  • spring-cloud-function-context(通过Function搜索)
  • lombok(通过Lombok搜索)
  1. dependency(通过Add a dependency from Maven Central Repository添加)
  • spring-file-supplier(通过File Supplier搜索)
  • spring-functions-catalog-bom(通过Functions Catalog搜索)

或者可以建一个基础的SPring Boot项目,然后添加starter和dependency

在JAVA工程里配置Qdrant链接、并配置OpenAI API:

yaml 复制代码
spring:
  application:
    name: data-loader
  ai:
    openai:
      base-url: https://dashscope.aliyuncs.com/compatible-mode  # Qwen的OpenAI式Endpoint
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
            model: qwen3.5-plus
      embedding:  # 新增嵌入模型配置
        options:
          model: text-embedding-v2  # 阿里云支持的嵌入模型
    vectorstore:
      qdrant:
        host: 127.0.0.1
        port: 6334
        api-key: ${QDRANT_API_KEY}
        initialize-schema: true
        collection-name: ai_demo
server:
  port: 0

4 数据导入

除了用Web管理后台导入、或者手动调用QdrantAPI,生产环境一般用JAVA调用QdrantAPI导入数据。

4.1 定义工作流

基于Spring Function Catalog创建工作流:

yaml 复制代码
spring:
  cloud:
    function:
      definition: >
        fileSupplier|
        documentReader|
        splitter|
        titleDeterminer|
        vectorStoreConsumer
file:
  supplier:
    directory: /var/dropoff
    filename-regex: .*\.(pdf|docx|txt)

注意的是,Windows中/var/dropoff指向的是JAVA工程所在盘符的对应目录,例如:
E:/var/dropoff

上面工作流的步骤说明:

  1. fileSupplier步骤的实现,直接由配置项file.supplier提供(参考Spring Functions Catalog相关文档)
  2. 后续步骤的实现,需要定义相关的@Bean;为了简化代码,全部定义在Startup
  3. 还需要定义一个启动@Bean
    代码如下:
java 复制代码
package com.junteam.ai.dataloader;

import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.function.Function;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
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.Value;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.function.context.FunctionCatalog;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;

import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;

@SpringBootApplication
public class DataLoaderApplication {
	private static final Logger log = LoggerFactory.getLogger(DataLoaderApplication.class);

	public static void main(String[] args) {
		SpringApplication.run(DataLoaderApplication.class, args);
	}

	@Bean
    Function<Flux<byte[]>, Flux<Document>> documentReader() {
        return resourceFlux -> resourceFlux
                .map(fileBytes -> new TikaDocumentReader(
                        new ByteArrayResource(fileBytes))
                        .get()
                        .getFirst())
                .subscribeOn(Schedulers.boundedElastic());
    }

    @Bean
    Function<Flux<Document>, Flux<List<Document>>> splitter() {
        var splitter = new TokenTextSplitter();
        return documentFlux -> documentFlux
                .map(incoming -> splitter
                        .apply(List.of(incoming)))
                .subscribeOn(Schedulers.boundedElastic());
    }

	@Value("classpath:/promptTemplates/nameOfTheCountry.st")
    Resource nameOfTheCountryTemplateResource;
	
    @Bean
    Function<Flux<List<Document>>, Flux<List<Document>>> titleDeterminer(ChatClient.Builder chatClientBuilder) {

        var chatClient = chatClientBuilder.build();

        return documentListFlux -> documentListFlux
                .map(documents -> {
                    if (!documents.isEmpty()) {
                        var firstDocument = documents.getFirst();

                        var countryTitle = chatClient.prompt()
                                .user(userSpec -> userSpec
                                        .text(nameOfTheCountryTemplateResource)
                                        .param("document", firstDocument.getText()))
                                .call()
                                .entity(CountryTitle.class);

                        if (Objects.requireNonNull(countryTitle).title().equals("未知")) {
                            log.warn("Unable to determine the name of a country; " +
                                    "not adding to vector store.");
                            documents = Collections.emptyList();
                            return documents;
                        }

                        log.info("Determined country title to be {}", countryTitle.title());
                        documents = documents.stream().peek(document -> {
                            document.getMetadata()
                                    .put("countryTitle", countryTitle.title());
                        }).toList();
                    }

                    return documents;
                });
    }

    @Bean
    Consumer<Flux<List<Document>>> vectorStoreConsumer(VectorStore vectorStore) {
        return documentFlux -> documentFlux
                .doOnNext(documents -> {
                    if (!documents.isEmpty()) {
                        var docCount = documents.size();
                        log.info("Writing {} documents to vector store.", docCount);

                        vectorStore.accept(documents);

                        log.info(
                                "{} documents have been written to vector store.", docCount);
                    }
                })
                .subscribe();
    }

    @Bean
    ApplicationRunner go(FunctionCatalog catalog) {
        Runnable composedFunction = catalog.lookup(null);
        return args -> {
            composedFunction.run();
        };
    }
}

其中本次的提示词模板:

复制代码
你的任务是根据文档(在"文档:"后面)中给出的规则来确定国家名称。
该文档将是国家说明的简短摘录。文档中可能会明确说明国家名称,也可能不会。
如果未明确说明国家名称,请将国家名称设置为"未知"。

文档:
{document}

4.2 补充内容

这样具体场景的流程如下:
flowchart LR subgraph VectorDB[向量数据库模块] VectorStore[(🗄️ 向量数据库<br>支持按国家/语义检索)]:::db end subgraph DataLoader[数据加载与预处理模块] direction TB OriginalDocs[📚 原始文档<br>国家历史地理风俗] --> EntityExtract[🏷️ 大模型自动抽取<br>国家名称 & 描述信息] EntityExtract --> Splitter[✂️ 文档分割器] Splitter --> DocChunks[📄 文档分块<br>含元数据: 国家/地区] DocChunks --> CalcEmb[🧮 计算向量嵌入] CalcEmb -->|存入向量与元数据| VectorStore end subgraph RAGApp[智能问答应用] direction TB Question[❓ 用户问题<br>例: 日本有哪些传统节日?] --> App[④ 应用入口]:::app App -->|1. 问题分析与意图识别| IntentCheck{是否涉及<br>已知国家/地区?} IntentCheck -->|是| SearchByCountry[🔍 按国家元数据过滤] IntentCheck -->|否| SemanticSearch[🔍 纯语义相似搜索] SearchByCountry -->|组合查询| VectorStore SemanticSearch --> VectorStore VectorStore -->|返回相似文档块<br>+ 所属国家信息| App App --> Prompt[⑤ 组装增强提示词<br>包含: 检索到的风俗/地理描述<br>+ 原始问题] Prompt --> LLM[🤖 大语言模型]:::llm LLM --> Answer[✨ 增强答案<br>例: 依据日本文化资料,主要节日有...] Answer --> App App --> Output[📢 输出答案] end classDef process fill:#a5d6a7,stroke:#388e3c,stroke-width:2px classDef app fill:#ffab91,stroke:#e64a19,stroke-width:2px classDef db fill:#bbdefb,stroke:#1976d2,stroke-width:2px classDef llm fill:#ffcc80,stroke:#f57c00,stroke-width:2px

可以看到:

RAG,通过搜索能力、增强大模型生成内容的质量。

核心的一个误解,就是搜索能力不是在大模型生成完之后补充结果、也不是把搜索内容导入到大模型内部。

而是组装到发给大模型的提示词里

5 启动与验证

5.1 启动

启动data-loader项目,然后在/var/dropoff文件夹里放入一些资料:

document-德国.txt

复制代码
德国各地有华人网站,比如德累斯顿华人网。

德国各地有华人微信群。

德国网购一般使用易贝、亚马逊平台。

德国买东西一般去超市。

德国生活用品购买常常去IDEL。

德国化妆品护肤品一般去ROSSMAN、DM购买。

德国购物节是黑色星期五。

document-意大利.txt

复制代码
意大利各地常常有特色的工艺品,比如威尼斯有面具,玻璃岛有玻璃制品,米兰的黄金首饰。

意大利罗马,市容卫生比较差,随地乱扔垃圾,而且小偷比较多。

5.2 验证

运行shell脚本(可以使用GitBash运行):

bash 复制代码
curl http://localhost:6333/collections

返回内容如下:

json 复制代码
{
  "result":{
  "collections":[{"name":"ai_demo"},{"name":"star_charts"}]},
  "status":"ok",
  "time":5.5e-6
}

这说明数据集(可以看作表)已经自动创建。

运行shell脚本(可以使用GitBash运行):

bash 复制代码
curl -X POST http://localhost:6333/collections/ai_demo/points/count \
  -H "Content-Type: application/json" \
  -d '{"exact": true}'

返回内容如下:

json 复制代码
{"result":{"count":2},"status":"ok","time":0.0004876}

这说明数据已经成功导入。

相关推荐
DZSpace2 小时前
小米MiMo-V2全家桶:AI智能体的“大脑、眼睛与声音”
ai
xingyuzhisuan3 小时前
适合推荐系统训练的GPU服务器配置选择
人工智能·ai
WitsMakeMen3 小时前
rq-vae训练过程是什么样的
llm
风流 少年4 小时前
frontend-design skill
ai
机器之心4 小时前
龙虾之后,为什么说「主动式智能」才是Agent的终极形态?
人工智能·openai
智算菩萨4 小时前
GPT-5.4 Pro与Thinking模型全面研究报告
人工智能·gpt·ai·chatgpt·ai-native
机器之心4 小时前
昨晚,OpenClaw大更新,亲手终结「旧插件」时代
人工智能·openai
GoCoding5 小时前
Triton + RISC-V
pytorch·openai·编译器
大卫小东(Sheldon)6 小时前
大模型智能体 (agent)简易流程介绍
ai·rust