1. 引言

在上一篇文章中,我们深入探讨了文本向量化的原理与实践。理解了文本向量化之后,接下来就可以尝试给大模型灌输一些自己的资料,然后让大模型能够尝试理解用户的自然提问后,结合自己灌输的资料给出更合理的答案。本文将以美团外卖常见问题为例,完整演示如何使用 LangChain4j 构建一个基于 RAG(检索增强生成)的智能客服知识库系统。

2. RAG 基础流程

AI 大模型回答所有问题都要基于他之前训练过的数据。如果 AI 大模型没有专门"学习"过美团的业务说明,那么面对一些跟美团业务直接相关的问题,例如"美团外卖中在线支付取消订单后钱怎么返还?"时,是无法给出理想的答案的。

如何让 AI 大模型能够"学习"美团的业务知识呢?目前有两种主要的方法:

  1. 微调(Fine-Tuning):在已有的预训练模型的基础上,根据特定的任务和数据集,对模型的参数进行进一步调整和优化。这种方式针对性更强,对特定领域问题的理解更准确,但成本更高,且针对其他领域性能不佳。

  2. RAG(检索增强生成):使用泛化的大模型,通过对问题和答案进行优化、增强,让大模型能够结合已有数据给出更准确的答案。这种方式成本相对较低,更适合处理涉及大量外部数据的特定问题。

RAG 通常分为两个阶段:

  • Indexing(索引阶段):对知识库进行处理,例如对知识库的内容进行 Embedding 向量化处理,并保存到向量数据库中。
  • Retrieval(检索阶段):当用户提出问题时,到向量数据库中检索出跟用户问题比较关联的"知识",整理成完整的 prompt,一起发送给大模型,由大模型对信息进行整合,再给用户正确答案。

例如可以定制这样的一个 prompt 模板:

你是一个问答机器人。
你的任务是根据下述给定的已知信息回答用户问题。
已知信息:
{context}  # {context}就是检索出来的文档
用户问:
{question}  # {question}就是用户的问题
如果已知信息不包含用户问题的答案,或者已知信息不足以回答用户的问题,请直接回复"我无法回答您的问题"。
请不要输出已知信息中不包含的信息或答案。
请用中文回答用户问题。

3. Index 索引阶段

3.1 引入依赖

首先,创建一个 Maven 项目,引入 LangChain4j 的核心依赖,以及 LangChain4j 中针对你要使用的大模型的扩展依赖:

<properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <langchain4j.version>0.35.0</langchain4j.version>
</properties>

<dependencies>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-zhipu-ai</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
        <version>0.35.0</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-redis</artifactId>
        <version>${langchain4j.version}</version>
    </dependency>
</dependencies>

然后,将美团外卖常见问题网页中的问题和答案整理成 txt 文档 meituan-questions.txt,放到项目的根路径当中。

3.2 加载并解析文件

LangChain4j 提供了 FileSystemDocumentLoader 工具,用来加载各种不同格式的知识库文件。这里使用 TextDocumentParser 来将文本内容封装成 Document。一个 Document 表示了一个包含一系列知识库内容的文件。

Path documentPath = Paths.get(CustomerServiceAgent.class.getClassLoader()
        .getResource("meituan-qa.txt").toURI());
DocumentParser documentParser = new TextDocumentParser();
Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);

3.3 切分文件

接下来,需要将 Document 中的文件切分成一个个比较独立的 Segments,一个 segment 就表示一条问答。切分的具体过程需要根据数据的格式来确定。对于按空行分隔的问答对格式,可以使用正则表达式 "\\s*\\R\\s*\\R\\s*" 进行切分。

LangChain4j 中提供了一系列 DocumentSplitter 工具来辅助进行切分。这里我们可以自定义一个工具类来完成切分:

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.segment.TextSegment;
import java.util.ArrayList;
import java.util.List;

public class MyDocumentSplitter implements DocumentSplitter {
    public static final String SPLIT_EXP = "\\s*\\R\\s*\\R\\s*";

    @Override
    public List<TextSegment> split(Document document) {
        List<TextSegment> segments = new ArrayList<>();
        String[] parts = document.text().split(SPLIT_EXP);
        for (String part : parts) {
            segments.add(TextSegment.from(part));
        }
        return segments;
    }
}

然后就可以用这个工具来对 Document 对象进行切分:

DocumentSplitter splitter = new MyDocumentSplitter();
List<TextSegment> segments = splitter.split(document);

这样切分出来的 segments 中包含的每一个 TextSegment 就是代表知识库中的一条知识,在这个示例中代表的就是一个问答对。

3.4 文本向量化

切分出我们需要的知识后,就可以对文本进行向量化处理,并将这些问答的向量化结果存储下来。

例如,使用 OpenAI 的文本向量化模型:

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
        .baseUrl(ModelUtil.BASE_URI_OPENAI)
        .apiKey(ModelUtil.API_KEY_OPENAI)
        .build();

List<Embedding> embeddings = embeddingModel.embedAll(segments).content();

通过文本向量化模型,就可以将文本转换成一个向量 Vector,形式上是一个很大的数组。

注意

  1. 向量化模型与聊天模型并不一定需要使用同一个大模型。例如使用智谱的 EmbeddingModel,后面组合使用 OpenAI 的 ChatLanguageModel,这也是可以的。
  2. 同样的文本,使用不同的大模型进行向量化处理,结果是不一样的,最终的效果也是不一样的。所以项目中通常需要多进行下尝试。

接下来就可以使用向量数据库来保存这些文本的向量化结果。例如,使用一个带有 Redis JSON 和 Redis Search 插件的 Redis 服务来存储这些向量。

Docker 部署 Redis 的简单指令:

docker run -p 6379:6379 redis/redis-stack-server:latest
EmbeddingStore<TextSegment> embeddingStore = RedisEmbeddingStore.builder()
        .host("127.0.0.1")
        .port(6379)
        .dimension(1536)
        .indexName("meituan-rag")
        .build();

embeddingStore.addAll(embeddings, segments);

这里 dimension 表示向量的维度,也就是文本向量化后的数组大小,通常跟选择的大模型有关,OpenAI 的向量化模型维度是 1536。需要跟大模型的向量化结果维度保持统一。

执行完毕后,就可以在 Redis 中查询:

redis-cli FT.SEARCH meituan-rag "*" LIMIT 0 1

如果能查到结果,就表示文本处理正常。

把这些代码整合到一起,就是一个本地 RAG 建立消息索引的基本流程:

public class MeituanRagLoader {
    public static void main(String[] args) throws URISyntaxException {
        Path documentPath = Paths.get(MeituanRagLoader.class.getClassLoader()
                .getResource("meituan-questions.txt").toURI());
        DocumentParser documentParser = new TextDocumentParser();
        Document document = FileSystemDocumentLoader.loadDocument(documentPath, documentParser);

        DocumentSplitter splitter = new MyDocumentSplitter();
        List<TextSegment> segments = splitter.split(document);

        EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
                .baseUrl(ModelUtil.BASE_URI_OPENAI)
                .apiKey(ModelUtil.API_KEY_OPENAI)
                .build();
        List<Embedding> embeddings = embeddingModel.embedAll(segments).content();

        EmbeddingStore<TextSegment> embeddingStore = RedisEmbeddingStore.builder()
                .host("127.0.0.1")
                .port(6379)
                .dimension(1536)
                .indexName("meituan-rag")
                .build();
        embeddingStore.addAll(embeddings, segments);
    }
}

4. Retrieval 搜索增强阶段

在这个阶段,主要是要围绕客户提出的问题做一些补充和优化。在接受到用户的一个问题后,我们通常需要先到向量数据库中去搜索一下跟用户提出的问题相关的知识。这样未来就可以把用户的问题和本地知识库中相关的知识一起发给大模型,让大模型综合考虑之后,给出一个理想的答案。

4.1 检索相关信息

要检索出跟用户的问题相关的知识,就需要先对用户的问题进行文本向量化,然后再去对应的向量数据库中检索出关联度比较高的一些 Document。整个这个过程,LangChain4j 提供了 ContentRetriever 工具来简化实现过程。

ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
        .embeddingStore(embeddingStore) // 向量存储模型
        .embeddingModel(embeddingModel) // 向量模型
        .maxResults(5) // 最相似的5个结果
        .minScore(0.8) // 只找相似度在0.8以上的内容
        .build();

通过构建的这个 contentRetriever 对象,就可以针对客户提出的某个问题,转成向量化后,去向量数据库中搜索相似度在 0.8 以上的 5 个结果。

现在,拿一个知识库中的原始问题作为测试:

String question = "在线支付取消订单后钱怎么返还?"; // 用户的问题
Query query = new Query(question);
List<Content> contentList = contentRetriever.retrieve(query);
for (Content content : contentList) {
    System.out.println(content);
}

查询结果如下:

Q:在线支付取消订单后钱怎么返还?
订单取消后,款项会在一个工作日内,直接返还到您的美团账户余额。
Q:在线支付订单如何退款?
商家接单前,您可以直接取消订单,订单金额会自动退款到美团余额;商家接单后,您在点击"申请退款",在线申请。提交退款申请之后,商家有24小时处理您的退款申请。商家同意退款,或24小时内没有处理您的退款申请,您的支付金额会退款至您的美团余额。
Q:在线支付的过程中,订单显示未支付成功,款项却被扣了,怎么办?
出现此问题,可能是银行/支付宝的数据没有即时传输至美团,请您不要担心,稍后刷新页面查看。如半小时后仍显示"未付款",请先联系银行/支付宝客服,获取您扣款的交易号,然后致电美团外卖客服4008507777,我们会协助您解决。
Q:申请退款后,商家拒绝了怎么办?
申请退款后,如果商家拒绝,此时回到订单页面点击"退款申诉",美团客服介入处理。
Q:前面下了一个在线支付的单子,由于未付款,订单自动取消了,这单会计算我的参与活动次数吗?
不会。如果是未支付的在线支付订单,可以先将订单取消(如果不取消需要15分钟后系统自动取消),订单无效后,此时您再下单仍会享受活动的优惠。

可以看到,这个查询结果还不错。不光查到了原始问题,而且其他几个结果也大都跟订单取消有关。

4.2 构建 Prompt 提示词

查询出跟用户问题相关的"知识"后,就需要将用户的问题和相关的"知识"整合到一起,才能发送给大模型。这个消息整合的过程,LangChain4j 也提供了一个工具:ContentInjector

ContentInjector contentInjector = new DefaultContentInjector();
UserMessage promptMessage = contentInjector.inject(contentList, UserMessage.from(question));

ContentInjector 是一个顶层接口,LangChain4j 中提供了一个默认的实现 DefaultContentInjector。而这个 DefaultContentInjectorinject 方法的实现方式实际上是定制了一个默认的消息模板:

public static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = PromptTemplate.from(
    "{{userMessage}}\n" +
    "\n" +
    "Answer using the following information:\n" +
    "{{contents}}"
);

将这个模板中大括号的部分替换成对应的内容,就组成了要发往大模型的实际消息。

实际上,定制消息模板,是提升大模型性能最为简单有效的一种方式。很多企业都会根据自己的业务需求定制更丰富的消息模板。

分享两个提示词网站:https://www.promptingguide.ai/zhhttps://www.aishort.top/

通过 ContentInjector 修饰后的消息,就可以发往大模型进行调用了:

ChatLanguageModel model = ModelUtil.getOpenAIModel();
Response<AiMessage> generate = model.generate(promptMessage);
System.out.println(generate.content().text());

运行后,就能看到发送给大模型的消息以及大模型的回复:

TextContent { text = "在线支付取消订单后钱怎么返还?
Answer using the following information:
Q:在线支付取消订单后钱怎么返还?
订单取消后,款项会在一个工作日内,直接返还到您的美团账户余额。
Q:在线支付订单如何退款?
...
" }

订单取消后,款项会在一个工作日内直接返还到您的美团账户余额。

这个过程中消耗 Token 约 513 个。

这里把所有代码整合到一起,就是一个简单的本地 RAG 检索的完整案例:

public class MeituanRagDemo {
    public static void main(String[] args) {
        EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
                .baseUrl(ModelUtil.BASE_URI_OPENAI)
                .apiKey(ModelUtil.API_KEY_OPENAI)
                .build();

        EmbeddingStore<TextSegment> embeddingStore = RedisEmbeddingStore.builder()
                .host("127.0.0.1")
                .port(6379)
                .dimension(1536)
                .indexName("meituan-rag")
                .build();

        ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(embeddingStore)
                .embeddingModel(embeddingModel)
                .maxResults(5)
                .minScore(0.8)
                .build();

        String question = "在线支付取消订单后钱怎么返还?";
        Query query = new Query(question);
        List<Content> contentList = contentRetriever.retrieve(query);

        ContentInjector contentInjector = new DefaultContentInjector();
        UserMessage promptMessage = contentInjector.inject(contentList, UserMessage.from(question));

        ChatLanguageModel model = ModelUtil.getOpenAIModel();
        Response<AiMessage> generate = model.generate(promptMessage);
        System.out.println(generate.content().text());
    }
}

5. 整合大模型:构建 AiService

在之前的简单 RAG 示例中,涉及到了很多个组件。接下来,如果你想要把这个 RAG 实现得更优雅一些,就可以使用 LangChain4j 提供的 AiServices 组件,把所有这些组件整合到一起。整合的过程中,还可以添加一些 Tools 工具等组件进行增强。最后经过一点简单的封装,就可以获得一个快速调用大模型的服务工具类。

import com.roy.ModelUtil;
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiEmbeddingModel;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
import dev.langchain4j.rag.content.injector.ContentInjector;
import dev.langchain4j.rag.content.injector.DefaultContentInjector;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.redis.RedisEmbeddingStore;
import java.time.LocalDateTime;

public class MeituanRagService {

    // 增强接口
    interface AiCustomer {
        String answer(String question);
    }

    public static AiCustomer create() {
        ChatLanguageModel chatLanguageModel = ModelUtil.getOpenAIModel();

        EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
                .baseUrl(ModelUtil.BASE_URI_OPENAI)
                .apiKey(ModelUtil.API_KEY_OPENAI)
                .build();

        EmbeddingStore<TextSegment> embeddingStore = RedisEmbeddingStore.builder()
                .host("127.0.0.1")
                .port(6379)
                .dimension(1536)
                .indexName("meituan-rag")
                .build();

        ContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
                .embeddingStore(embeddingStore)
                .embeddingModel(embeddingModel)
                .maxResults(5)
                .minScore(0.8)
                .build();

        ContentInjector contentInjector = new DefaultContentInjector();

        DefaultRetrievalAugmentor retrievalAugmentor = DefaultRetrievalAugmentor.builder()
                .contentRetriever(contentRetriever)
                .contentInjector(contentInjector)
                .build();

        ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

        return AiServices.builder(AiCustomer.class)
                .chatLanguageModel(chatLanguageModel)
                .retrievalAugmentor(retrievalAugmentor)
                .tools(new DataCalculator())
                .chatMemory(chatMemory)
                .build();
    }

    // 工具类
    static class DataCalculator {
        @Tool("计算指定天数后的具体日期")
        String date(Integer days) {
            return LocalDateTime.now().plusDays(days).toString();
        }
    }

    public static void main(String[] args) {
        // 获得代理的服务对象
        AiCustomer aiCustomer = MeituanRagService.create();

        // 通过代理服务对象调用大模型
        String result = aiCustomer.answer("今天的余额提现,最晚哪天能到账?给我具体的日期");
        System.out.println(result);
    }
}

执行这个 main 方法,就能拿到 OpenAI 给出的响应结果:

如果您今天提现余额,根据美团的政策,最晚可能在2024年11月1日前到账。

这个案例为了简化实现,封装得不是很合理。把对象拆开,就可以得到一个很好用的 AiCustomer 工具类。如果再对接上一个对应的前端,一个简单的智能问答系统就算是初具雏形了。大家也完全可以按照这一套基本流程搭建企业内部的系统。

6. 总结与优化方向

通过本文的实战,我们完整地走通了使用 LangChain4j 构建 RAG 智能问答系统的全过程,包括:

  1. 索引阶段:加载文件 → 切分文档 → 文本向量化 → 存储到向量数据库
  2. 检索阶段:用户问题向量化 → 向量数据库检索 → 构建 Prompt → 调用大模型
  3. 整合阶段:使用 AiServices 将各组件优雅地整合在一起

当然,在这个简单的 RAG 智能问答系统的基础上,还有很多地方是需要优化的:

  • 文档切分策略:根据文档格式选择更合适的切分方式,如按段落、按句子或按固定长度切分
  • 向量化模型选择:尝试不同的 Embedding 模型,找到最适合业务场景的模型
  • 检索策略优化:调整 maxResultsminScore 参数,或使用混合检索(关键词 + 向量)
  • Prompt 模板定制:根据业务需求定制更丰富的消息模板
  • 多轮对话支持:结合 ChatMemory 实现上下文记忆
  • 工具函数扩展:添加更多 Tool 工具,让大模型具备更多能力

希望本文能帮助你快速上手 LangChain4j 的 RAG 开发,构建属于你自己的智能问答系统!

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐