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}

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

相关推荐
爱吃的小肥羊14 分钟前
Codex 今天开始重大更新,全面解读,确实有点东西!
aigc·openai
慕峯38 分钟前
反蒸馏 Skill 安装使用教程
ai
用户5720660614611 小时前
Kiro 免费额度,够用吗
openai
垚森2 小时前
我用AI写了一个颜值拉满的桌面媒体播放器,全程没动一行代码,这就是AI编程新范式
ai·electron·react·opencode
Java小白笔记2 小时前
什么是 Token?2026 年主流大模型计费规则、价格与性能全面对比
人工智能·ai·ai编程·ai写作
阿正的梦工坊2 小时前
vLLM 底层 PagedAttention(分页注意力)和 Continuous Batching(连续批处理)解释
llm
进击的松鼠2 小时前
从对话到动作:用 Function Calling 把 LLM 接到真实 API(含流程拆解)
python·llm·agent
Java小白笔记3 小时前
Claude-Code 完全指南
人工智能·ai·全文检索·ai编程·ai写作
岳小哥AI3 小时前
5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具
ai·openclaw·workbuddy
山顶夕景4 小时前
【LLM后训练】看Off-Policy and On-Policy Learning
llm·distillation·蒸馏·posttraining