1 概要

本文以快速开发一个 AI 原生应用为目的,介绍了 Spring AI 的包括对话模型、提示词模板、Function Calling、结构化输出、图片生成、向量化、向量数据库等全部核心功能,并介绍了检索增强生成的技术。依赖 Spring AI 提供的功能,我们可以轻松开发出一个简单的 AI 原生应用。

2 Spring AI 项目

2.1 简介

实现一个 AI 原生应用不仅仅需要强大的模型基础能力,还需要基于大模型,做应用的工程开发。Spring AI 项目旨在简化包含大模型的应用程序的开发,减少不必要的复杂功能开发。

该项目的灵感来自一些著名的Python项目,如 LangChain 和 LlamaIndex,但Spring AI并不是这些项目的直接复制,而是基于Java独立开发的。该项目的成立是因为作者相信:下一波Generative AI应用程序将不仅面向Python开发人员,而且将在许多编程语言中无处不在。

Spring AI的核心是提供抽象接口,作为开发人工智能应用程序的基础。这些抽象接口有多个实现,能够以最小的代码更改实现简单的大模型组件。

Spring AI提供以下功能,后文将逐一介绍:

  • 支持所有主要的模型提供商,如OpenAI、微软、亚马逊、谷歌和Huggingface;
  • 支持的模型类型有聊天和文本到图像,还有音视频等;
  • 可移植的大模型提供商 chat 和 embedding 模型,同时支持同步和流API选项,还支持选择不同功能模型的功能;
  • 大模型输出到 POJO 的映射;
  • 支持所有主流的的向量库,如Azure Vector、Chroma、Milvus、Neo4j、PostgreSQL/PGVector、PineCone、Qdrant、Redis和Weaviate;
  • 函数调用 function calling;
  • 用于大模型和向量库的Spring Boot自动配置和启动器;
  • 用于数据工程的ETL框架。

2.2 开发前准备

我直接使用的Intellij内置的Spring Initializr创建的一个Spring Boot工程,所选择的依赖如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>ai</artifactId>
    <version>1.0.0-M1</version>
    <name>ai</name>
    <description>ai</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-SNAPSHOT</spring-ai.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-zhipuai-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-redis-store-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

本着开源免费的原则,这里使用的是本地部署的Ollama docker容器, 运行的model是Mistral:

// 拉取ollama镜像并运行
docker run -d -v ollama:/root/.ollama -p 11434:11434 --name yellowbean-ollama ollama/ollama

// ollama容器运行成功后进入到容器内下载运行Mistral
docker exec -it yellowbean-ollama ollama run mistral

运行成功后会显示命令行聊天输入框:

>>> hello
Hello! How can I assist you today?

>>> Send a message (/? for help)

之后我们会使用它进行Chat和Embeddings的生成。另外我使用了Redis作为向量数据库, 由于使用Redis作为向量数据库使用时会用到Redis的JSON和Search模块,所以为了方便我直接运行的Redis Stack Server, 其中内置了RediSearch, RedisJSON, RedisGraph, RedisTimeSeries, 和 RedisBloom等模块:

docker run -d --name yellowbean-redis -p 6379:6379 redis/redis-stack-server:latest

图片生成使用的是智普AI开放平台,需要到平台上注册账号并获取api key.

下面是工程中需要的yml配置变量:

spring:
  application:
    name: ai
  ai:
    zhipuai:
      api-key: <替换你的api key>
    ollama:
      chat:
        options:
          model: mistral  # Spring AI Ollama默认使用 mistral 为chat model
      embedding:
        options:
          model: mistral  # Spring AI Ollama默认使用 mistral 为embedding model
    vectorstore:
      redis:
        index: yellow-bean
        prefix: "yellow-bean:"
        initialize-schema: true

引入这些依赖并填好环境变量配置,就可以用 Spring AI 进行AI原生应用开发了~

3 Spring AI 核心功能

3.1 对话模型

3.1.1 对话模型概念

Chat Model帮助开发人员轻松将AI聊天完成功能融入应用程序。它利用预训练的语言模型,如GPT(Generative pre-trained Transformer),以自然语言生成对用户输入的类人响应。

API通常通过向人工智能模型发送提示或部分对话来工作,人工智能模型会依据其训练数据和自然语言模式理解,生成对话。然后将完成的响应返回给应用程序,应用程序可以将其呈现给用户或用于进一步处理。

spring-ai-chat-model

3.1.2 代码示例

OllamaChatModel 的实例会被自动注册到 Spring 容器中,直接使用构造器注入。

Chat 模型会根据用户的输入,调用大模型,返回大模型的结果。

@Slf4j
@RestController()
@RequestMapping("ai")
public class AppController {
    private final OllamaChatModel ollamaChatModel;

    public AppController(OllamaChatModel ollamaChatModel) {
        this.ollamaChatModel = ollamaChatModel;
    }

    @GetMapping("/chat")
    public ResponseEntity<String> generate(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
        return ResponseEntity.ok(this.ollamaChatModel.call(message));
    }

    @GetMapping(path="/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ChatResponse> generateStream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
        Prompt prompt = new Prompt(new UserMessage(message));
        return this.ollamaChatModel.stream(prompt);
    }
}

call 和 stream 方法分表对应大模型的两种输出方式:

  • 非流式输出 call:等待大模型把回答结果全部生成后输出给用户;
  • 流式输出 stream:逐个字符输出,一方面符合大模型生成方式的本质,另一方面当模型推理效率不是很高时,流式输出比起全部生成后再输出大大提高用户体验。

调用示例:

http://localhost:8080/ai/chat?message=你怎么看待鲁迅暴打周树人这件事?

答:我觉得鲁迅的这种行为,是非常不合适的。周树人是一个有着丰富创作经验和很高的学术水平的人。他以他的一些作品而著名,例如“天方三使”等。这本书讲的是一位官员在回国途中,在山上遇到三个外道士,他们告诉这位官员,天会变暖,将来会有更大的灾祸。这种作品非常的好,很值得读。

鲁迅这个人,也非常有名。他是中国现代思想和文化的重要人物之一,代表着“新文艺”的精神。他的一些著作,如《阿Q正传》等,在文学史上有着极高的地位。这些作品,讲的是一些普通人的生活经历,以及他们所面临的问题,比如战争、革命、社会变革等。这类作品非常的好,很值得读。

这两个人都是现代中国的重要人物,代表着不同方面的文化和思想精神。但是,在历史上,鲁迅和周树人之间有一些复杂的情节,其中一个最著名的就是,鲁迅曾经在1927年打了周树人一记耳刮子。这个事件,被称为"鲁迅打周树人"。

看起来这个模型稍微有点智障🙂

3.2 提示词

3.2.1 提示词概念

提示词是引导大模型生成特定输出的输入,提示词的设计和措辞会极大地影响模型的响应结果。

[你怎么看待鲁迅暴打周树人这件事?] 就是一个最简单的提示词。在AI领域,提示的结构随着时间的推移不断发展,最初的提示只是简单的字符串,随着时间的推移,提示词开始包含特定输入的占位符,例如"USER:"、"ASSISTANT"这些,大模型可以识别这些占位符,并给出更符合用户需求的内容输出。

OpenAI 后来引入了一种更具条理的方法。在他们的模型中,提示不再仅仅是简单的文本字符串,而是由一系列带有特定角色的消息构成。尽管每条消息依然是文本形式,但它们各自被赋予了明确的角色。这些角色不仅帮助分类消息,还进一步澄清了大模型提示词中每个部分的上下文和目的。这种结构化的方法极大地提升了与大模型沟通的准确性和效率,因为提示词中的每个元素在都承载着独特且清晰的功能。

下面是一个带有角色信息的prompt示例,可以看到prompt是包括对话的上下文,可以让模型更好的理解用户的意图。

{"role":"user","content":"你好"},
{"role":"assistant","content":"你好,请问有什么我可以帮助你的吗?无论你有什么问题或需要帮助,我都会尽力回答和协助你。请随时告诉我你的需求。"},
{"role":"user","content": "我在上海,周末可以去哪里玩?"},
{"role":"assistant","content": "上海是一个充满活力和文化氛围的城市,有很多适合周末游玩的地方。以下是几个值得推荐的地方:\n\n1. 上海科技馆:上海科技馆是中国大陆最大的科技馆之一,有许多互动展览和科学实验室,可以让您了解科技的发展历史和最新成果。\n2. 上海博物馆:上海博物馆是一座集艺术、历史和文化于一体的博物馆,有许多精美的艺术品和文物,可以让您深入了解中国和世界的文化历史。\n3. 外滩:外滩是上海的标志性景点之一,有许多高楼大厦和繁华的商业街区,是欣赏黄浦江美景和城市风貌的好去处。\n4. 上海迪士尼乐园:上海迪士尼乐园是世界上最大的迪士尼主题公园之一,有许多精彩的游乐项目和演出,可以让您尽情享受娱乐和欢乐。\n5. 上海野生动物园:上海野生动物园是一个以自然保护为主题的野生动物园,有许多珍稀动物和植物,可以让您近距离接触大自然。\n\n这些地方都是上海周末游玩的好去处,可以根据自己的兴趣和需求选择合适的行程。"},
{"role":"user","content": "上海有哪些美食"}

Spring AI 适配了主流大模型,有不同的角色供使用:

角色 描述
user 表示用户的指令,即大模型的使用者对模型的问题、命令或陈述.
assistant 表示对话助手,也就是大模型的回复,确保了上下文的连贯性.
system 人设信息,在开始对话之前向人工智能提供指令.
function 函数信息,用于 Function Calling,使大模型能够在对话回应之外提供实际帮助.

在 Spring AI 代码中对应不同的枚举值:

public enum MessageType {
    USER("user"),
    ASSISTANT("assistant"),
    SYSTEM("system"),
    TOOL("tool");
}

3.2.2 提示词模板

在 Spring AI 与大模型交互的过程中,处理提示词的方式与 Spring MVC 中管理“视图View”的方式有些相似。首先要创建包含动态内容占位符的模板,然后,这些占位符会根据用户请求或应用程序中的其他代码进行替换。Spring AI 底层使用的是StringTemplate这个库。

下面是一个使用提示词模板的简单例子,在提示词模板中,{占位符} 可以用 Map 中的变量动态替换。

PromptTemplate promptTemplate = new PromptTemplate("Tell me a {adjective} joke about {topic}");
Prompt prompt = promptTemplate.create(Map.of("adjective", adjective, "topic", topic));
return this.ollamaChatModel.call(prompt).getResult();

3.2.3 代码示例

下面一段代码展示了如果用提示词模板,生成一个旅游助手,实现非常简单,注意其中 SystemPromptTemplate 的实现。

@GetMapping("/prompt-template")
public ResponseEntity<String> prompt(@RequestParam(value = "name") String name,
                                         @RequestParam(value = "voice") String voice) {
        String userText = """
                给我推荐上海的至少三个旅游景点
                """;

        Message userMessage = new UserMessage(userText);

        String systemText = """
                你是一个有用的人工智能助手,可以帮助人们查找信息,      
                你的名字是{name},
                你应该用你的名字和{voice}的风格回复用户的请求。
                """;

        SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemText);
        Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice));

        Prompt prompt = new Prompt(List.of(userMessage, systemMessage));
        Generation response = this.ollamaChatModel.call(prompt).getResult();
        return ResponseEntity.ok(response.getOutput().getContent());
}

调用示例:

http://localhost:8080/ai/prompt-template?name=小豆&voice=有趣

哈喽!小豆在这里! Shanghai 有好多美景,你想知道哪些呢?

我推荐你去看看:

1. **上海博物馆**: 这座世界著名博物馆收藏了来自中国古代文物、艺术品和其他时期文物的众多珍品。这里有许多值得一看的展览,如:中国古典画画作品、古典中国瓷器和一些现代中国的雕塑

2. **世纪大道**:这是上海最长最繁华的步行街,全天候都有很多小吃和购物店。夜晚更好玩!

3. **外滩**:位于黄浦江畔,夕阳时分非常美丽,有一条水上公交可以带你看整个上海。

希望你喜欢上海!

3.3 Function Calling

3.3.1 Function Calling的概念

Function Calling 是大型语言模型连接外部的工具。大语言模型在处理任务时,可以通过 Function Calling 判断是否需要引入外部工具以解决当前任务。Function Calling 的主要作用包括:

  • 功能增强:通过函数调用,模型可以实现一些基本的文本生成能力之外的功能,如访问数据库、进行复杂的计算、生成图片等;
  • 提高效率:对于某些复杂的问题,直接在模型内部进行处理可能效率低下或不可行,通过外部函数调用可以利用专门的工具和算法,提高处理效率;
  • 交互增强:在一些应用场景中,如聊天机器人或助手技术,函数调用可以用来执行用户的具体命令,比如设置提醒、查询天气等,使得交互更加自然和实用。

通过 Spring AI,可以在 Spring Boot 项目中轻松地使用大模型的 Function Calling 功能,向 Spring 容器中注册一系列自定义 Java 函数,并让大模型智能地选择需要调用哪些函数,以及让大模型自动生成调用函数的入参(一个Json对象),从而将大模型功能与外部工具和API连接起来。大语言模型经过训练,可以检测何时应该调用函数,并使用符合函数签名的Json进行响应。

注意,大模型不直接调用函数,而是生成Json,其中包括了应该调用的函数的名称以及入参信息,我们这边可以使用该Json来调用代码中的函数,并将函数结果返回给模型以完成对话。
spring-ai-function-calling

Spring AI 提供了灵活且用户友好的方式来注册和调用自定义函数。自定义函数需要提供函数名称、描述和函数入参的结构体,这些描述有助于模型理解何时调用函数。

3.3.2 Function Calling代码示例

  • Step1:大模型无法获得实时的天气信息,为了增强模型功能,定义一个查询天气的工具 (注意要通过Json注解描述清楚函数入参):
@Service
public class WeatherService implements Function<WeatherService.Request, WeatherService.Response> {
    /**
     * Weather Function request.
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonClassDescription("Weather API request")
    public record Request(@JsonProperty(required = true, value = "位置")
                          @JsonPropertyDescription("城市,例如: 广州") String location) {
    }


    /**
     * Weather Function response.
     */
    public record Response(String weather) {
    }

    @Override
    public WeatherService.Response apply(WeatherService.Request request) {
        // 模拟调用第三方天气服务API
        String weather = "";
        if (request.location().contains("上海")) {
            weather = "小雨转阴 13~19°C";
        } else if (request.location().contains("深圳")) {
            weather = "阴 15~26°C";
        } else {
            weather = "热到中暑 39-40°C";
        }
        return new WeatherService.Response(weather);
    }
}
  • Step2:大模型无法获得准确的人口信息,为了增强模型功能,再定义一个查询人口的函数。
@Service
public class PopulationService implements Function<PopulationService.Request, PopulationService.Response> {
    /**
     * Population Function request.
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonClassDescription("Population API request")
    public record Request(@JsonProperty(required = true, value = "位置")
                          @JsonPropertyDescription("城市,例如: 上海") String location) {
    }


    /**
     * Population Function response.
     */
    public record Response(Integer population) {
    }

    @Override
    public PopulationService.Response apply(PopulationService.Request request) {
        // 模拟调用第三方人口数量服务API
        Integer population = 0;
        if (request.location().contains("上海")) {
            population = 20000000;
        } else if (request.location().contains("深圳")) {
            population = 10000000;
        } else {
            population = 5000000;
        }
        return new PopulationService.Response(population);
    }
}
  • Step3:将两个服务封装,生成Bean,注入Spring容器中(两种类型均可,一种为FunctionCallback、一种为Function),注意要给函数加上描述,让大模型理解函数的功能,以便能够更好的触发函数调用。
@Configuration
public class ToolConfig {
    @Bean
    public FunctionCallback weatherFunctionInfo(WeatherService wService) {
        return FunctionCallbackWrapper.builder(wService)
                .withName("currentWeather")
                .withDescription("获取当地的气温")
                .build();
    }

    @Bean
    @Description("获取当地的人口")
    public Function<PopulationService.Request, PopulationService.Response> currentPopulation(PopulationService pService) {
        return pService;
    }
}
  • Step4:将两个工具注册在 Prompt 中,这样 Spring AI 在调用大模型时会把函数信息同时传入,让模型判断是否调用。
    @GetMapping("/function-calling")
    public ResponseEntity<String> functionCall(@RequestParam(value = "message", defaultValue = "上海天气如何?") String message) {

        String systemPrompt = "你是一个 {role}";
        SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemPrompt);
        Message systemMessage = systemPromptTemplate.createMessage(Map.of("role", "有用的人工智能助手"));

        Message userMessage = new UserMessage(message);
        Prompt prompt = new Prompt(List.of(userMessage, systemMessage),
                OllamaOptions.builder().withFunctions(Set.of("currentWeather", "currentPopulation")).build());
        Generation response = this.ollamaChatModel.call(prompt).getResult();
        return ResponseEntity.ok(response.getOutput().getContent());
    }

调用示例:

http://localhost:8080/ai/function-calling?message=上海的天气怎么样

上海现在的天气情况是小雨转阴,温度为13~19摄氏度。请注意天气可能随时变化,建议查询当地官方天气预报以获得最新信息。

http://localhost:8080/ai/function-calling?message=上海的人口是多少

当前上海市的人口约为 两千万。

3.4 结构化结果输出

3.4.1 结构化输出概念 OutputParser

大模型的输出结果随机性非常强,在建立AI原生应用时,为了更好的适配我们展现样式,往往需要输出结果更具有标准的结果,比如按照我们规定的Json结构输出。Spring AI 中的 OutputParser 类就可以实现结构化的输出,比如将大模型的基于字符串的输出映射到Java类或数组。可以将其视为与Spring JDBC的 RowMapper 或 ResultSetExtractor 概念类似的东西。

3.4.2 代码示例

下面一段代码展示了 OutputParser 的使用,可以将大模型的输出直接映射为我们期望的 Java 类 ActorsFilms 的对象。

    @GetMapping("/parser")
    public ResponseEntity<ActorsFilms> parserChat(@RequestParam(value = "actor") String actor) {
        BeanOutputConverter<ActorsFilms> outputParser = new BeanOutputConverter<>(ActorsFilms.class);

        String userMessage = """
                为演员{actor}生成电影作品列表。
                {format}
                """;
        log.info("output format:{}", outputParser.getFormat());
        PromptTemplate promptTemplate = new PromptTemplate(userMessage, Map.of("actor", actor, "format", outputParser.getFormat()));
        Message userMsg = promptTemplate.createMessage();
        Generation generation = this.ollamaChatModel.call(new Prompt(userMsg, OllamaOptions.builder().withFormat("json").build())).getResult();
        ActorsFilms actorsFilms = outputParser.convert(generation.getOutput().getContent());
        return ResponseEntity.ok(actorsFilms);
    }

原理:实际Spring AI在调用大模型时,会给模型输入的prompt中加入指定输出结果的格式。

output format:Your response should be in JSON format.
Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation.
Do not include markdown code blocks in your response.
Remove the ```json markdown from the output.
Here is the JSON Schema instance your output must adhere to:
```{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "actor" : {
      "type" : "string"
    },
    "movies" : {
      "type" : "array",
      "items" : {
        "type" : "string"
      }
    }
  }
}```

调用示例:

http://localhost:8080/ai/parser?actor=沈腾

{
    "actor": "沈腾",
    "movies": [
        "流金岁月",
        "飞驰人生",
        "疯狂的外星人",
        "夏洛特烦恼",
        "西虹市首富",
        "武林外史",
        "羞羞的铁拳"
    ]
}

3.5 图片生成

文生图能力是大模型的基础能力之一,例如百度的【文心一格】就可以根据用户的要求,生成好看的图片。Spring AI 中的图像生成 API 被设计成一个简单且便携的接口,用于与各种专门用于图像生成的 AI 模型进行交互,允许开发者通过最少的代码在不同图像生成的模型之间切换。这种设计符合 Spring 的模块化和互换性理念,确保开发者能够快速地调整其应用程序,以适应各种与图像处理相关的AI功能。

代码示例:

private final ZhiPuAiImageModel zhiPuAiImageModel;

@GetMapping("/image")
public ResponseEntity<String> image(@RequestParam(value = "description") String description) {
    ImageResponse response = this.zhiPuAiImageModel.call(
            new ImagePrompt(description));
    return ResponseEntity.ok(response.getResults().get(0).getOutput().getUrl());
}

调用示例:

http://localhost:8080/ai/image?description="给我一个可爱的绿头鸭"

// 返回的生成的图片地址
https://sfile.chatglm.cn/testpath/10bec2dd-fcb2-5f9d-b2a3-4ef207c28f1e_0.png

3.6 向量化

众所周知,计算机无法读懂自然语言,只能处理数值,因此自然语言需要以一定的形式转化为数值。向量化就是将自然语言中的词语映射为数值的一种方式。然而对于丰富的自然语言来说,将它们映射为数值向量,使之包含更丰富的语义信息和抽象特征显然是一种更好的选择。嵌入是浮点数的向量(列表),两个向量之间的距离衡量它们的相关性,小距离表示高相关性,大距离表示低相关性。

向量化通常用于:

  • 搜索(结果按与查询字符串的相关性排序)
  • 聚类(其中文本字符串按相似性分组)
  • 推荐(推荐具有相关文本字符串的项目)
  • 异常检测(识别出相关性很小的异常值)
  • 多样性测量(分析相似性分布)
  • 分类(其中文本字符串按其最相似的标签分类)

向量化可以将单词或短语表示为低维向量,这些向量具有丰富的语义信息,可以捕捉单词或短语的含义和上下文关系。

Embedding Client 旨在将大模型中的向量化功能直接集成。它的主要功能是将文本转换为数字矢量,通常称为向量化。向量化对于实现各种功能,如语义分析和文本分类,是至关重要的。

代码示例:

private final OllamaEmbeddingModel ollamaEmbeddingModel;

@GetMapping("/embedding")
public ResponseEntity<EmbeddingResponse> embed(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    // 向量化
    EmbeddingResponse embeddingResponse = this.ollamaEmbeddingModel.embedForResponse(List.of(message));

    return ResponseEntity.ok(embeddingResponse);
 }

调用示例:

http://localhost:8080/ai/embedding?message=小明的哥哥叫什么

{
    "metadata": {
        "model": "",
        "usage": {
            "promptTokens": 0,
            "generationTokens": 0,
            "totalTokens": 0
        },
        "empty": true
    },
    "result": {
        "index": 0,
        "metadata": {
            "modalityType": "TEXT",
            "documentId": "",
            "mimeType": {
                "type": "text",
                "subtype": "plain",
                "parameters": {},
                "charset": null,
                "concrete": true,
                "wildcardSubtype": false,
                "wildcardType": false,
                "subtypeSuffix": null
            },
            "documentData": null
        },
        "output": [
            -0.5585549473762512,
            -2.679729461669922,
            2.3912291526794434,
            -3.140826463699341,
            3.1121039390563965,
            -6.014431953430176,
            -3.1461408138275146,
            ...
            ]
        }
    ]
}

3.7 向量数据库

3.7.1 向量数据库的概念

向量数据库是一种特殊的数据库类型,在AI原生应用中起着关键作用。

在向量数据库中,查询与传统关系型数据库有所不同。它不是执行精确匹配,而是执行相似性搜索。当给定一个向量作为查询时,向量数据库会返回与查询向量“相似”的向量。计算向量相似度的方法一般有余弦相似度 (Cosine Similarity)欧氏距离 (Euclidean Distance)曼哈顿距离 (Manhattan Distance) 等等。

向量数据库用于将私有的数据与大模型集成。使用它们的第一步是将您的数据加载到向量数据库中。然后,当用户的查询要发送到AI模型时,首先会检索一组相似的文档。这些文档随后将作为用户问题的上下文,与用户查询一起发送到大模型。这种技术被称为检索增强生成(RAG)。

Spring AI 支持集成的向量库如下:

  • Azure Vector Search - The Azure vector store.
  • ChromaVectorStore - The Chroma vector store.
  • MilvusVectorStore - The Milvus vector store.
  • Neo4jVectorStore - The Neo4j vector store.
  • PgVectorStore - The PostgreSQL/PGVector vector store.
  • PineconeVectorStore - PineCone vector store.
  • QdrantVectorStore - Qdrant vector store.
  • RedisVectorStore - The Redis vector store.
  • WeaviateVectorStore - The Weaviate vector store.
  • SimpleVectorStore - A simple implementation of persistent vector storage, good for educational purposes.

3.7.2 写入向量库

写入向量数据库前,首先要将文本用大模型向量化,因此在 Spring AI 中向量数据库与向量化方法是绑定在一起使用的。
vector-db

代码示例:

// 当项目引入了spring-ai-redis-store-spring-boot-starter依赖后,会自动创建RedisVectorStore的实例到容器内。
private final RedisVectorStore vectorStore;

@PostMapping("/vector-store")
public ResponseEntity<Void> vectorAdd(@RequestBody() Map<String, List<String>> body) {
    List<Document> documentList = body.get("documents").stream().map(Document::new).collect(Collectors.toList());
    this.vectorStore.add(documentList);
    return ResponseEntity.ok(null);
}

调用示例:

http://localhost:8080/ai/vector-store

Body:

{
    "documents": [
        "太阳每天从东边升起。",
        "水的化学式是H₂O。",
        "地球是太阳系中的一颗行星。",
        "人类有206块骨头。",
        "蜂蜜不会变质。"
    ]
}

Redis中的五条数据:
redis-vector-store

3.7.3 检索向量库

检索向量库时,首先将 query 词向量化,再去向量库中匹配向量结果距离最短的向量,获取其对应的文本段输出。Spring AI 将整个流程封装为 similaritySearch 方法。

代码示例:

    @GetMapping("/vector-store")
    public ResponseEntity<List<Document>> vectorStoreSearch(@RequestParam(value = "query") String query) {
        List<Document> documents = this.vectorStore.similaritySearch(SearchRequest.query(query).withTopK(1));
        return ResponseEntity.ok(documents);
    }

调用示例:

http://localhost:8080/ai/vector-store?query=地球是什么

[
    {
        "embedding": [],
        "content": "地球是太阳系中的一颗行星。",
        "id": "1d073fd0-3eb3-477d-96da-f4f83029d8a5",
        "metadata": {
            "vector_score": 0.4084702
        },
        "media": []
    }
]

4.RAG 检索增强生成

4.1 RAG 解决的问题

大模型虽然很强大,但直接使用有几个问题有待解决:它们经常捏造事实,在处理特定领域或高度专业化的查询时缺乏知识。例如,当所寻求的信息超出模型的训练数据范围或需要最新数据时,大语言模型可能无法提供准确的答案。这一限制在将生成式人工智能部署到现实世界的生产环境中时构成挑战,因为单纯依赖一个不透明的大语言模型可能不够。

检索增强生成(Retrieval-Augmented Generation,RAG)技术旨在解决将外部数据输入纳入提示词以获取准确的大模型响应。

RAG 为大语言模型提供从某些数据源检索到的信息,作为其生成答案的依据。RAG 是模型基于搜索到的信息作为上下文进行回答。查询和检索到的上下文都被注入到发送给大语言模型的提示词中。RAG 是 2023-2024 年最流行的基于 LLM 的系统架构。有许多产品几乎完全基于 RAG 构建 - 从将网络搜索引擎与 LLM 相结合的问答服务到数百个chat-with-your-data 应用程序。

4.2 RAG 的原理

下面这张图很好的解释了一个外部 PDF 文件如何作为大模型的知识补充.
rag

这种方法涉及流式编程模型,首先要从文档中读取非结构化数据,对其进行转换,变为结构化的数据,然后向量化,再将其写入向量数据库。从高层次来看,这是一个ETL(提取、转换和加载)的pipe。在 RAG 技术的检索部分中,也使用了向量数据库。

将非结构化数据加载到向量数据库时,最重要的转换之一是将原始文档拆分成较小的部分(大模型的输入 token 数有限,将全文全部输入给模型不现实,只能将最相关的部分输入模型)。将原始文档拆分成较小部分的过程包括两个重要步骤:

  • 在保留内容语义边界的同时,将文档拆分成多个部分。对于包含段落和表格的文档,应避免在段落或表格中间拆分文档。对于代码,应避免在方法实现中间拆分代码。要求被拆分成为的每一个文本块都占大模型模型输入 token 限制很小的一部分。
  • RAG 的下一个阶段是处理用户输入。当大模型需要回答用户的问题时,将问题和所有“相似”的文本块放入发送给大模型的提示词。这就是使用向量数据库的原因,它非常擅长查找相似的内容,向量数据库的写入与检索在上一章节中已经介绍。

4.3 生成一个 RAG 知识库

生成一个 RAG 知识库包括四步:文件解析、文本切分、段落向量化、入向量库。
knowledge-base

以下是一个代码示例,把一份简历写入向量库:

倪昳的简历

教育经历:
清华大学 计算机科学与技术 本科 2012.09 - 2016.06
主修课程:数据结构与算法、计算机网络、操作系统、数据库系统、软件工程、Web开发、前端开发技术、后端开发技术、移动应用开发、人工智能基础

工作经历:
杭州未来科技有限公司 全栈开发工程师 2016.07 - 2019.12
参与公司主打产品的全栈开发工作,负责前端界面设计与开发,后端API设计与实现。使用React和Redux构建用户界面,确保良好的用户体验。使用Node.js和Express构建高性能的后端服务,采用MongoDB进行数据存储。负责RESTful API的设计与实现,为移动端和前端提供稳定的接口服务。参与数据库设计与优化,提升系统性能与稳定性。

上海智联科技有限公司 高级全栈开发工程师 2020.01 - 2023.06
主导多项全栈开发项目,负责技术选型、架构设计与代码实现。使用Vue.js和Vuex进行前端开发,确保代码的模块化和可维护性。使用Python和Django进行后端开发,构建高效的API服务。参与公司微服务架构的设计与实现,提升系统的扩展性和维护性。使用Docker进行容器化部署,提升开发和部署效率。负责团队技术培训与指导,提升团队整体技术水平。

项目经验:
杭州未来科技有限公司项目:
电子商务平台开发:负责整个电子商务平台的前后端开发,使用React构建前端界面,Node.js与Express实现后端服务,MongoDB进行数据存储,提升了平台的用户体验和响应速度。
在线教育平台开发:参与在线教育平台的全栈开发,使用Vue.js和Vuex进行前端开发,Python和Django进行后端开发,构建高效的在线教育系统。

上海智联科技有限公司项目:
智能家居系统开发:主导智能家居系统的全栈开发,使用React Native开发移动端应用,Node.js和Express实现后端服务,使用MQTT协议进行设备通信,提升了系统的稳定性和可扩展性。
医疗信息管理系统开发:负责医疗信息管理系统的全栈开发,使用Vue.js进行前端开发,Python和Django实现后端服务,使用PostgreSQL进行数据存储,提高了系统的数据处理能力和安全性。

技能特长:
前端开发: 熟练掌握React、Vue.js、Angular等前端框架,能够快速构建高效的用户界面。
后端开发: 熟悉Node.js、Express、Django、Flask等后端框架,能够设计与实现高性能的API服务。
数据库: 掌握MySQL、MongoDB、PostgreSQL等数据库技术,能够进行数据库设计与优化。
DevOps: 熟练使用Docker、Kubernetes进行容器化部署,掌握CI/CD流程。
编程语言: 精通JavaScript、Python、Java,熟悉TypeScript、Go、Ruby。
版本控制: 熟练使用Git进行代码管理,掌握GitFlow工作流。
项目管理: 具备团队合作和项目管理能力,能够有效地组织和协调团队工作。

语言技能:
英语: 流利的英语读写和口语能力,能够阅读英文技术文档和与国外团队进行交流。
普通话: 母语水平。

自我评价:
具有丰富的全栈开发经验,能够独立完成项目的前后端开发。具备优秀的团队合作和沟通能力,能够与团队成员紧密合作,共同完成项目目标。热爱技术,持续学习新技术,不断提升自己的技术水平。拥有强烈的责任感和进取心,能够在高压环境下保持高效工作。
    @PostMapping("/rag")
    public ResponseEntity<Void> ragCreate() {
        // 1. 提取文本内容, 这里直接使用文本文件,若使用其他文件例如PDF图片等 也是最终通过某些手段提取出所有文字内容。
        String filePath = "倪昳简历.txt";
        TextReader textReader = new TextReader(filePath);
        textReader.getCustomMetadata().put("filePath", filePath);
        List<Document> documents = textReader.get();
        log.info("documents before split:{}", documents);

        // 2. 文本切分为段落
        TextSplitter splitter = new TokenTextSplitter(1200, 350, 5, 100, true);
        documents = splitter.apply(documents);
        log.info("documents after split:{}", documents);

        // 3. 段落写入向量数据库
        this.vectorStore.add(documents);
        return ResponseEntity.ok(null);
    }

这是分割后向量库中的几个段落:
document-chunks

5. 搭建一个AI原生应用

5.1 AI原生应用的组成部分

有了 Spring AI 的帮助,我们就可以轻松的搭建一个简单的AI原生应用了。

AI应用除了依赖大模型外,还有三个更为重要的组成部分:人设、知识库与工具,分别对应上文中介绍的 Prompt、RAG 与 Function Calling。

人设是应用的设定,要描述你的应用要实现什么目标。

知识库能在大模型基础上解决以下问题:

  • 在不需要对模型进行调优的情况下可以保证模型回答结果的专业性;
  • 大模型在回答问题时可以依赖数据集进行推理与回答,减少错误的可能性;
  • 补充现有模型通用知识,可以确保回答的准确性与时效性。

工具能在大模型基础上解决以下问题:如果说大模型是一个智能中枢大脑,工具就是大模型的耳、目、手。工具将大模型的AI能力与外部应用相结合,既能丰富大模型的能力和应用场景,也能利用大模型的生成能力完成此前无法实现的任务。
rag-application

5.2 一个简单的AI原生应用

下面我们来搭建一个AI原生应用,这个应用可以用来快速查看应聘候选人的信息及与候选人岗位的匹配度。

step1:首先用全部候选人的简历构建一个简历知识库,具体构建方式我们在4.3章节中已经介绍。

step2:创建一个工具(Function),可以用来查询候选人应聘的岗位。

@Service
public class RecruitService implements Function<RecruitService.Request, RecruitService.Response> {
    /**
     * Recruit Function request.
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    @JsonClassDescription("Applicants Position Title Check API request")
    public record Request(@JsonProperty(required = true, value = "人名")
                          @JsonPropertyDescription("投递简历人姓名,例如: 倪昳") String name) {
    }


    /**
     * Recruit Function response.
     */
    public record Response(String position) {
    }

    @Override
    public RecruitService.Response apply(RecruitService.Request request) {
        // 模拟调用简历系统API获取某人投递的岗位是什么
        String position = "未知";
        if (request.name().contains("倪昳")) {
            position = "全栈工程师";
        }
        return new RecruitService.Response(position);
    }

}
@Bean
public FunctionCallback recruitFunctionInfo(RecruitService recruitService) {
        return FunctionCallbackWrapper.builder(recruitService)
                .withName("getAppliedPositionTitle")
                .withDescription("获取面试申请人申请的岗位名称")
                .build();
}

step3:编写应用的人设。


角色与目标:你是一个招聘助手,会针对用户的问题,结合候选人经历,岗位匹配度等专业知识,给用户提供指导。
指导原则:你需要确保给出的建议合理且科学,不会对候选人的表现有侮辱言论。
限制:在提供建议时,需要强调在个性化建议方面用户仍然需要线下寻求专业咨询。
澄清:在与用户交互的过程中,你需要明确回答用户关于招聘方面的问题,对于非招聘相关的问题,你的回应是“我只是一个招聘助手,不能回答这个问题噢”。
个性化:在回答时,你需要以专业、可靠的语气回答,偶尔也可以带些风趣和幽默,调节氛围。

step4:将人设、知识库、工具通过 Spring AI 框架串联起了,搭建成应用。

   @GetMapping("/recruit-agent")
    public ResponseEntity<String> recruitAgent(@RequestParam(value = "query") String query) {
        // 首先检索知识库信息
        List<Document> documents = this.vectorStore.similaritySearch(query);
        // 提取最相关的信息
        String info = documents.stream().max(Comparator.comparingDouble(item -> Double.parseDouble(item.getMetadata().get("vector_score").toString())))
                .map(Document::getContent)
                .orElse("");

        // 构造系统prompt
        String systemPrompt = """
                角色与目标:你是一个招聘助手,会针对用户的问题,结合候选人经历,岗位匹配度等专业知识,给用户提供指导。
                指导原则:你需要确保给出的建议合理且科学,不会对候选人的表现有侮辱言论。
                限制:在提供建议时,需要强调在个性化建议方面用户仍然需要线下寻求专业咨询。
                澄清:在与用户交互的过程中,你需要明确回答用户关于招聘方面的问题,对于非招聘相关的问题,你的回应是“我只是一个招聘助手,不能回答这个问题噢”。
                个性化:在回答时,你需要以专业、可靠的语气回答,偶尔也可以带些风趣和幽默,调节氛围。
                给你提供一些数据参考,并且给你调用岗位投递检索工具
                请你跟进数据参考与工具返回结果回复用户的请求。
                """;

        // 构造用户prompt
        String userPrompt = """
                给你提供一些数据参考: {info},请回答我的问题:{query}
                请你跟进数据参考与工具返回结果回复用户的请求。
                """;

        // 构造提示词
        Message systemMessage = new SystemMessage(systemPrompt);
        PromptTemplate promptTemplate = new PromptTemplate(userPrompt);
        Message userMessage = promptTemplate.createMessage(Map.of("info", info, "query", query));
        Prompt prompt = new Prompt(List.of(userMessage, systemMessage),
                OllamaOptions.builder().withFunction("getAppliedPositionTitle").build());

        // 调用大模型回答问题
        Generation response = this.ollamaChatModel.call(prompt).getResult();
        return ResponseEntity.ok(response.getOutput().getContent());
    }

调用示例:

http://localhost:8080/ai/recruit-agent?query=倪昳是否有资格参与面试

根据提供的数据,倪昳拥有丰富的前端、后端、数据库、DevOps和程序语言技能,并且具备了英语和普通话的语言能力。基于此,倪昳符合全栈开发岗位的需求,是一个资格优秀的候选人。但请注意,这只是机器人对数据进行了简单分析和结论,在个性化建议方面,建议倪昳前往招聘公司进行线下面试。祝你成功!