JAVA 构建企业聊天机器人?向量检索+角色 Prompt 设计完整流程

构建企业聊天机器人:向量检索 + 角色 Prompt 设计完整流程

大家好,今天我们来聊聊如何构建一个企业级的聊天机器人,重点在于如何利用向量检索和角色 Prompt 设计,来实现更智能、更贴合业务需求的对话体验。

一、整体架构设计

一个典型的企业聊天机器人系统可以分为以下几个核心模块:

  1. 用户界面 (UI): 负责接收用户输入,并将机器人的回复呈现给用户。可以是网页、APP、微信小程序等形式。
  2. 消息处理模块: 接收 UI 传递过来的用户消息,进行预处理,例如:文本清洗、分词等。
  3. 意图识别模块 (Intent Recognition): 识别用户的意图,例如:查询信息、办理业务、闲聊等。这里我们重点关注查询信息场景,并使用向量检索来高效实现。
  4. 知识库 (Knowledge Base): 存储企业内部的各种知识,例如:产品信息、FAQ、流程文档等。 我们将把知识库的内容转化为向量,方便后续的相似度检索。
  5. 向量检索模块: 根据用户的意图和输入,从知识库中检索最相关的知识片段。
  6. Prompt 生成模块: 根据用户的意图、检索到的知识片段以及预设的角色 Prompt,生成最终的 Prompt。
  7. 大语言模型 (LLM): 接收 Prompt,生成机器人的回复。
  8. 回复后处理模块: 对 LLM 的回复进行后处理,例如:格式化、过滤敏感信息等。
  9. 日志记录模块: 记录用户的对话、系统的运行状态等,用于后续的分析和优化。

二、知识库构建与向量化

2.1 知识库构建

首先,我们需要构建一个包含企业知识的知识库。 知识库的格式可以根据实际情况选择,例如:

  • FAQ 列表: 每个 FAQ 包含一个问题和对应的答案。
  • 文档集合: 可以是 PDF、Word、TXT 等格式的文档。
  • 数据库: 如果知识存储在数据库中,可以直接从数据库中提取。

假设我们有一个简单的 FAQ 列表,存储在 faqs.csv 文件中,格式如下:

question,answer
"你们的产品有哪些型号?","我们有 A 型、B 型和 C 型三种型号的产品。"
"产品的保修期是多久?","产品的保修期为一年。"
"如何联系客服?","您可以拨打我们的客服电话:400-xxxx-xxxx。"

2.2 向量化

我们需要将知识库中的内容转化为向量,以便进行相似度检索。常用的向量化方法包括:

  • TF-IDF: 一种传统的文本向量化方法,但效果相对较差。
  • Word2Vec / GloVe: 词向量模型,可以将每个词转化为向量。
  • Sentence Transformers: 专门用于生成句子向量的模型,效果更好。

这里我们选择使用 Sentence Transformers。 首先,安装 Sentence Transformers 库:

pip install sentence-transformers

然后,编写 Java 代码进行向量化:

import ai.djl.huggingface.tokenizers.Encoding;
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.djl.inference.InferenceModel;
import ai.djl.ndarray.NDArray;
import ai.djl.ndarray.NDList;
import ai.djl.repository.zoo.Criteria;
import ai.djl.training.util.PairList;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;

import java.io.FileReader;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

public class VectorizeKnowledgeBase {

    private static final String MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"; // 选择一个预训练模型
    private static final String TOKENIZER_PATH = "tokenizers";

    public static void main(String[] args) throws IOException, CsvException {
        // 1. 加载知识库
        List<String> questions = loadQuestionsFromCSV("faqs.csv");

        // 2. 初始化模型和 Tokenizer
        Criteria<String, NDList, NDList> criteria = Criteria.builder()
                .setTypes(String.class, NDList.class, NDList.class)
                .optModelName(MODEL_NAME)
                .optModelPath(Paths.get("models").toAbsolutePath().toString())  // 模型下载路径
                .optOption("device", "cpu") // 可选: "cpu" 或 "gpu"
                .build();

        try (InferenceModel model = criteria.loadModel()) {
            HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(Paths.get(TOKENIZER_PATH).resolve(MODEL_NAME).toString());

            // 3. 向量化每个问题
            List<float[]> vectors = new ArrayList<>();
            for (String question : questions) {
                float[] vector = vectorize(question, model, tokenizer);
                vectors.add(vector);
                System.out.println("Vectorized question: " + question);
            }

            // 4. 保存向量 (这里只是打印,实际应该保存到向量数据库)
            for (int i = 0; i < vectors.size(); i++) {
                System.out.println("Question: " + questions.get(i) + ", Vector: " + vectors.get(i).length + " dimensions");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static List<String> loadQuestionsFromCSV(String filePath) throws IOException, CsvException {
        List<String> questions = new ArrayList<>();
        try (CSVReader reader = new CSVReader(new FileReader(filePath))) {
            List<String[]> records = reader.readAll();
            // 跳过标题行
            for (int i = 1; i < records.size(); i++) {
                String[] record = records.get(i);
                questions.add(record[0]); // 第一列是问题
            }
        }
        return questions;
    }

    private static float[] vectorize(String text, InferenceModel model, HuggingFaceTokenizer tokenizer) throws IOException {
        Encoding encoding = tokenizer.encode(text);
        long[] indices = encoding.getIds();
        long[] attentionMask = encoding.getAttentionMask();

        NDArray inputIds = model.getNDManager().create(indices);
        NDArray attentionMaskArray = model.getNDManager().create(attentionMask);

        NDList list = new NDList(inputIds, attentionMaskArray);
        PairList<String, NDArray> inputs = new PairList<>();
        inputs.add("input_ids", inputIds);
        inputs.add("attention_mask", attentionMaskArray);

        NDList output = model.predict(new NDList(inputIds, attentionMaskArray));

        // Pooling strategy (mean pooling)
        NDArray embeddings = output.get(0); // Assuming the first output is the embeddings
        NDArray maskExpanded = attentionMaskArray.expandDims(-1).broadcast(embeddings.getShape());
        NDArray maskedEmbeddings = embeddings.mul(maskExpanded);
        NDArray sumEmbeddings = maskedEmbeddings.sum(new int[]{1});
        NDArray sumMask = attentionMaskArray.sum(new int[]{1});
        NDArray pooledEmbeddings = sumEmbeddings.div(sumMask);

        return pooledEmbeddings.toFloatArray();
    }
}

代码解释:

  • MODEL_NAME: 指定使用的 Sentence Transformers 模型。
  • loadQuestionsFromCSV(): 从 CSV 文件中读取问题列表。
  • vectorize(): 使用 Sentence Transformers 模型将文本转化为向量。
    • 首先使用 HuggingFaceTokenizer 将文本分词并转化为 token ID。
    • 然后将 token ID 传入模型,得到 embedding。
    • 最后使用 Mean Pooling 的方式,将所有 token 的 embedding 聚合成一个句子向量。
  • 向量保存: 这里只是简单地打印了向量的维度,实际应用中应该将向量保存到向量数据库,例如:Milvus、Weaviate、Pinecone 等。

三、向量检索

当用户输入问题时,我们需要将问题转化为向量,然后在知识库中检索最相似的向量。

3.1 用户问题向量化

使用与知识库向量化相同的方法,将用户输入的问题转化为向量。

3.2 相似度计算

计算用户问题向量与知识库中每个向量的相似度。 常用的相似度计算方法包括:

  • 余弦相似度 (Cosine Similarity): 最常用的相似度计算方法。
  • 点积 (Dot Product): 如果向量已经归一化,点积和余弦相似度等价。
  • 欧氏距离 (Euclidean Distance): 也可以用来计算相似度,但需要将距离转化为相似度。

3.3 检索

选择相似度最高的 N 个向量作为检索结果。

示例代码:

import ai.djl.huggingface.tokenizers.Encoding;
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.djl.inference.InferenceModel;
import ai.djl.ndarray.NDArray;
import ai.djl.ndarray.NDList;
import ai.djl.repository.zoo.Criteria;
import ai.djl.training.util.PairList;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;

import java.io.FileReader;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;

public class VectorSearch {

    private static final String MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2";
    private static final String TOKENIZER_PATH = "tokenizers";
    private static final int TOP_K = 3; // 返回最相似的 TOP K 个结果

    public static void main(String[] args) throws IOException, CsvException {
        // 1. 加载知识库
        List<String> questions = loadQuestionsFromCSV("faqs.csv");

        // 2. 初始化模型和 Tokenizer
        Criteria<String, NDList, NDList> criteria = Criteria.builder()
                .setTypes(String.class, NDList.class, NDList.class)
                .optModelName(MODEL_NAME)
                .optModelPath(Paths.get("models").toAbsolutePath().toString())
                .optOption("device", "cpu")
                .build();

        try (InferenceModel model = criteria.loadModel()) {
            HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(Paths.get(TOKENIZER_PATH).resolve(MODEL_NAME).toString());

            // 3. 向量化知识库
            List<float[]> knowledgeBaseVectors = new ArrayList<>();
            for (String question : questions) {
                float[] vector = vectorize(question, model, tokenizer);
                knowledgeBaseVectors.add(vector);
            }

            // 4. 用户输入问题
            String userQuestion = "你们的产品保修多久?";

            // 5. 向量化用户问题
            float[] userQuestionVector = vectorize(userQuestion, model, tokenizer);

            // 6. 向量检索
            PriorityQueue<SearchResult> topKResults = search(userQuestionVector, knowledgeBaseVectors, questions, TOP_K);

            // 7. 打印检索结果
            System.out.println("User Question: " + userQuestion);
            System.out.println("Top " + TOP_K + " Similar Questions:");
            while (!topKResults.isEmpty()) {
                SearchResult result = topKResults.poll();
                System.out.println("Question: " + result.question + ", Similarity: " + result.similarity);
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static List<String> loadQuestionsFromCSV(String filePath) throws IOException, CsvException {
        List<String> questions = new ArrayList<>();
        try (CSVReader reader = new CSVReader(new FileReader(filePath))) {
            List<String[]> records = reader.readAll();
            for (int i = 1; i < records.size(); i++) {
                String[] record = records.get(i);
                questions.add(record[0]);
            }
        }
        return questions;
    }

    private static float[] vectorize(String text, InferenceModel model, HuggingFaceTokenizer tokenizer) throws IOException {
        Encoding encoding = tokenizer.encode(text);
        long[] indices = encoding.getIds();
        long[] attentionMask = encoding.getAttentionMask();

        NDArray inputIds = model.getNDManager().create(indices);
        NDArray attentionMaskArray = model.getNDManager().create(attentionMask);

        NDList list = new NDList(inputIds, attentionMaskArray);
        PairList<String, NDArray> inputs = new PairList<>();
        inputs.add("input_ids", inputIds);
        inputs.add("attention_mask", attentionMaskArray);

        NDList output = model.predict(new NDList(inputIds, attentionMaskArray));

        // Pooling strategy (mean pooling)
        NDArray embeddings = output.get(0);
        NDArray maskExpanded = attentionMaskArray.expandDims(-1).broadcast(embeddings.getShape());
        NDArray maskedEmbeddings = embeddings.mul(maskExpanded);
        NDArray sumEmbeddings = maskedEmbeddings.sum(new int[]{1});
        NDArray sumMask = attentionMaskArray.sum(new int[]{1});
        NDArray pooledEmbeddings = sumEmbeddings.div(sumMask);

        return pooledEmbeddings.toFloatArray();
    }

    private static PriorityQueue<SearchResult> search(float[] queryVector, List<float[]> knowledgeBaseVectors, List<String> questions, int topK) {
        PriorityQueue<SearchResult> topKResults = new PriorityQueue<>((a, b) -> Float.compare(a.similarity, b.similarity)); // Min-heap

        for (int i = 0; i < knowledgeBaseVectors.size(); i++) {
            float[] knowledgeBaseVector = knowledgeBaseVectors.get(i);
            double similarity = cosineSimilarity(queryVector, knowledgeBaseVector);

            SearchResult result = new SearchResult(questions.get(i), similarity);

            if (topKResults.size() < topK) {
                topKResults.offer(result);
            } else if (similarity > topKResults.peek().similarity) {
                topKResults.poll(); // Remove the smallest
                topKResults.offer(result);
            }
        }
        return topKResults;
    }

    private static double cosineSimilarity(float[] vectorA, float[] vectorB) {
        double dotProduct = 0.0;
        double magnitudeA = 0.0;
        double magnitudeB = 0.0;

        for (int i = 0; i < vectorA.length; i++) {
            dotProduct += vectorA[i] * vectorB[i];
            magnitudeA += Math.pow(vectorA[i], 2);
            magnitudeB += Math.pow(vectorB[i], 2);
        }

        magnitudeA = Math.sqrt(magnitudeA);
        magnitudeB = Math.sqrt(magnitudeB);

        if (magnitudeA == 0.0 || magnitudeB == 0.0) {
            return 0.0;
        }

        return dotProduct / (magnitudeA * magnitudeB);
    }

    private static class SearchResult {
        String question;
        double similarity;

        public SearchResult(String question, double similarity) {
            this.question = question;
            this.similarity = similarity;
        }
    }
}

代码解释:

  • search(): 实现向量检索功能。
    • 计算用户问题向量与知识库中每个向量的余弦相似度。
    • 使用 PriorityQueue 维护一个大小为 topK 的最小堆,用于存储相似度最高的 topK 个结果。
  • cosineSimilarity(): 计算两个向量的余弦相似度。

四、角色 Prompt 设计

Prompt 的质量直接影响 LLM 的回复质量。 一个好的 Prompt 应该包含以下信息:

  • 角色 (Role): 指定 LLM 的角色,例如:客服代表、技术专家等。
  • 任务 (Task): 明确 LLM 需要完成的任务。
  • 上下文 (Context): 提供 LLM 需要的上下文信息,例如:用户的历史对话、检索到的知识片段等。
  • 格式 (Format): 指定 LLM 的回复格式,例如:JSON、Markdown 等。

4.1 角色定义

根据不同的业务场景,定义不同的角色。 例如:

  • 客服代表: 负责回答用户的问题,解决用户的疑问。
  • 技术专家: 负责提供技术支持,解决技术问题。
  • 销售顾问: 负责推荐产品,促成销售。

4.2 Prompt 模板

设计 Prompt 模板,方便后续生成 Prompt。 例如:

你是一位专业的{role},你的任务是{task}。

以下是一些相关的知识片段:
{knowledge_fragments}

用户的问题是:{user_question}

请根据以上信息,用简洁明了的语言回答用户的问题。

4.3 Prompt 生成

根据用户意图、检索到的知识片段以及 Prompt 模板,生成最终的 Prompt。

示例代码:

public class PromptGenerator {

    public static String generatePrompt(String role, String task, List<String> knowledgeFragments, String userQuestion) {
        StringBuilder promptBuilder = new StringBuilder();
        promptBuilder.append("你是一位专业的").append(role).append(",你的任务是").append(task).append("。nn");
        promptBuilder.append("以下是一些相关的知识片段:n");
        for (String fragment : knowledgeFragments) {
            promptBuilder.append("- ").append(fragment).append("n");
        }
        promptBuilder.append("n用户的问题是:").append(userQuestion).append("nn");
        promptBuilder.append("请根据以上信息,用简洁明了的语言回答用户的问题。");

        return promptBuilder.toString();
    }

    public static void main(String[] args) {
        String role = "客服代表";
        String task = "回答用户关于产品保修期的问题";
        List<String> knowledgeFragments = List.of("产品的保修期为一年。");
        String userQuestion = "你们的产品保修多久?";

        String prompt = generatePrompt(role, task, knowledgeFragments, userQuestion);
        System.out.println(prompt);
    }
}

输出的 Prompt:

你是一位专业的客服代表,你的任务是回答用户关于产品保修期的问题。

以下是一些相关的知识片段:
- 产品的保修期为一年。

用户的问题是:你们的产品保修多久?

请根据以上信息,用简洁明了的语言回答用户的问题。

五、大语言模型调用

将生成的 Prompt 传递给大语言模型 (LLM),获取 LLM 的回复。 常用的 LLM 包括:

  • OpenAI GPT 系列: GPT-3, GPT-3.5, GPT-4 等。
  • Google PaLM 系列: PaLM 2 等。
  • 开源模型: LLaMA, ChatGLM 等。

示例代码 (调用 OpenAI API):

import okhttp3.*;
import org.json.JSONArray;
import org.json.JSONObject;

import java.io.IOException;
import java.util.List;

public class LLMCaller {

    private static final String API_KEY = "YOUR_OPENAI_API_KEY"; // 替换为你的 OpenAI API Key
    private static final String MODEL_NAME = "gpt-3.5-turbo"; // 选择一个模型

    public static String callLLM(String prompt) throws IOException {
        OkHttpClient client = new OkHttpClient();

        MediaType mediaType = MediaType.parse("application/json");
        JSONObject jsonBody = new JSONObject();
        jsonBody.put("model", MODEL_NAME);

        JSONArray messages = new JSONArray();
        JSONObject userMessage = new JSONObject();
        userMessage.put("role", "user");
        userMessage.put("content", prompt);
        messages.put(userMessage);

        jsonBody.put("messages", messages);
        jsonBody.put("temperature", 0.7); // 调整 temperature 控制生成结果的随机性

        RequestBody body = RequestBody.create(mediaType, jsonBody.toString());

        Request request = new Request.Builder()
                .url("https://api.openai.com/v1/chat/completions")
                .post(body)
                .addHeader("Authorization", "Bearer " + API_KEY)
                .addHeader("Content-Type", "application/json")
                .build();

        try (Response response = client.newCall(request).execute()) {
            if (!response.isSuccessful()) {
                throw new IOException("Unexpected code " + response);
            }

            String responseBody = response.body().string();
            JSONObject jsonResponse = new JSONObject(responseBody);
            JSONArray choices = jsonResponse.getJSONArray("choices");
            JSONObject choice = choices.getJSONObject(0);
            JSONObject message = choice.getJSONObject("message");
            return message.getString("content");
        }
    }

    public static void main(String[] args) throws IOException {
        String prompt = "你是一位专业的客服代表,你的任务是回答用户关于产品保修期的问题。nn" +
                "以下是一些相关的知识片段:n" +
                "- 产品的保修期为一年。nn" +
                "用户的问题是:你们的产品保修多久?nn" +
                "请根据以上信息,用简洁明了的语言回答用户的问题。";

        String response = callLLM(prompt);
        System.out.println("LLM Response: " + response);
    }
}

代码解释:

  • API_KEY: 替换为你的 OpenAI API Key。
  • callLLM(): 调用 OpenAI API,获取 LLM 的回复。
    • 构建 JSON 请求体,包含模型名称、Prompt 和其他参数。
    • 发送 HTTP POST 请求到 OpenAI API。
    • 解析 JSON 响应,提取 LLM 的回复。

六、回复后处理

对 LLM 的回复进行后处理,例如:

  • 格式化: 将 LLM 的回复格式化为易于阅读的格式。
  • 过滤敏感信息: 过滤掉 LLM 回复中的敏感信息。
  • 添加引用: 如果 LLM 的回复引用了知识库中的内容,添加引用链接。

七、持续优化

聊天机器人的构建是一个持续优化的过程。 可以通过以下方式进行优化:

  • 分析用户对话: 分析用户的对话,了解用户的问题和需求。
  • 优化知识库: 根据用户的问题和需求,不断完善知识库。
  • 调整 Prompt: 根据 LLM 的回复质量,不断调整 Prompt。
  • 评估指标: 使用评估指标,例如:准确率、召回率、满意度等,来评估聊天机器人的性能。

总结:

构建企业聊天机器人涉及多个环节,包括知识库构建、向量化、向量检索、角色 Prompt 设计、LLM 调用和回复后处理。 通过合理的架构设计和持续优化,可以构建一个智能、高效、贴合业务需求的聊天机器人。

项目总结:

从知识库的构建和向量化,到向量检索的实现,再到Prompt工程和LLM的对接,最终实现一个企业级的聊天机器人。需要注意的是,数据质量决定了上限,而Prompt设计决定了下限,后续的优化需要根据实际情况进行迭代和调整。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注