基于Ollama部署Deepseek

环境与模型准备

首先需要在本地拉取所需的模型文件:

  1. 推理模型:拉取 DeepSeek R1 (1.5b版本)

    1
    ollama pull deepseek-r1:1.5b
  2. 向量模型:拉取文本嵌入模型

    1
    ollama pull nomic-embed-text

两种响应方式

一、非流式响应

适用于不需要实时打字机效果的场景,直接返回完整结果。

1
2
3
4
5
6
7
8
9
10
public Response<ChatResponse> generate(@RequestParam("model") String model, 
@RequestParam("message") String message,
@RequestParam(value = "knowledgeBase", required = false) String knowledgeBase) {
// 封装统一响应结构
return Response.<ChatResponse>builder()
.code("0000")
.info("调用成功")
.data(aiService.generate(model, message, knowledgeBase))
.build();
}

二、流式响应(见后文RAG核心流程)


RAG(检索增强生成)

核心概念:为什么需要RAG?

大模型的回答本质上是没有“记忆”的,它只能根据你当前输入的Prompt进行预测。大模型之所以看起来“有记忆”,是因为我们每次对话时都把之前的聊天记录重新喂给了它。

RAG(Retrieval-Augmented Generation) 即知识检索增强,是为了解决大模型无法回答私有或最新数据的问题。

举个例子:

如果你直接问大模型:“我昨天晚上吃的啥?” 它肯定不知道,因为它没有你生活的训练数据。

但是,如果你提前将你的生活记录(知识库)存入向量数据库,当你提问时,系统会先去库里检索,找到“昨天晚上吃的红苕稀饭”这条记录,然后带着这个答案去问大模型。这时,它就能准确回复你。

下面是启用与不启用知识库的对比效果:

部署示意图


RAG 实现流程一:解析文件到向量库

这个过程主要包含四个步骤:解析、分块、编码、入库

1. 数据库连通性检查与文件遍历

在处理文件前,先确保向量数据库(PGVector)连接正常,随后遍历上传的文件列表。

1
2
3
4
5
6
try {
pgJdbcTemplate.execute("SELECT 1"); // 验证数据库连接
} catch (Exception e) {
return Response.<String>builder().code("1001").info("数据库连接失败").build();
}
// 遍历 file : files ...

2. 文档解析与切分

这是最关键的一步。我们首先利用 TikaDocumentReader 将各种格式(PDF/Word等)的文件转为纯文本,然后使用 TokenTextSplitter 将长文本切分为适合向量化的小块。

1
2
3
4
5
6
// 读取文件内容
TikaDocumentReader documentReader = new TikaDocumentReader(file.getResource());
List<Document> documents = documentReader.get();

// 文本切分
List<Document> documentSplitterList = tokenTextSplitter.apply(documents);

关于切分策略的配置:

Spring AI 提供了默认参数,我们也可以按需覆盖,防止空参异常:

  • defaultChunkSize = 800:默认切分大小 800 Tokens。
  • minChunkSizeChars = 350:最小字符数,防止切出太碎的片段导致语义丢失。
  • minChunkLengthToEmbed = 5:小于这个长度不进行向量化。
  • maxNumChunks = 10000:防止大文件导致内存溢出。
  • keepSeparator = true:保留分隔符,维持句子完整性。

3. 元数据打标与向量存储

为了区分不同知识库(比如“财务制度”和“技术文档”),我们需要在 Document 的 Metadata 中打上标签(ragTag)。

1
2
3
4
5
// 给文档打上知识库标签
documentSplitterList.forEach(doc -> doc.getMetadata().put("knowledge", ragTag));

// 存入 PGVector (底层调用 nomic-embed-text 模型进行向量化)
pgVectorStore.accept(documentSplitterList);

4. 更新缓存

最后,将知识库标签存入 Redis,方便前端快速查询当前有哪些知识库可用。

1
2
3
4
RList<String> elements = redissonClient.getList("ragTag");
if (!elements.contains(ragTag)) {
elements.add(ragTag); // 维护标签列表
}

RAG 实现流程二:核心检索与生成

当用户发起提问时,系统需要判断是否“强制使用知识库”。流程如下:

  1. 用户指定知识库 -> 检索相关信息 -> 注入提示词 -> 模型回答。
  2. 用户未指定 -> 直接由模型自由回答。

方法入口:

1
public Flux<ChatResponse> generateStream(String model, String message, String knowledgeBase)

步骤 1:初始化与模式判断

指定使用的本地模型(deepseek-r1:1.5b),并判断是否开启 RAG 模式。

1
2
OllamaOptions options = OllamaOptions.create().withModel(model);
// if (knowledgeBase != null) ... 判断逻辑

步骤 2:向量检索(Retrieval)

如果启用了知识库,我们需要把用户的自然语言问题转化为向量,在 PGVector 中搜索最相似的片段。

注意这里的过滤逻辑:

必须使用 withFilterExpression,确保只在当前选定的 knowledgeBase 范围内搜索,避免跨库串词。

1
2
3
4
5
SearchRequest request = SearchRequest.query(message)
.withTopK(5) // 取最相似的前5段
.withFilterExpression("knowledge == '" + knowledgeBase + "'");

List<Document> documents = pgVectorStore.similaritySearch(request);

步骤 3:构建上下文

将检索到的 5 段分散的文档内容,拼接成一个完整的字符串,作为“背景知识”喂给大模型。

1
2
3
String documentsCollectors = documents.stream()
.map(Document::getContent)
.collect(Collectors.joining());

步骤 4:构造系统提示词

这是防止大模型“胡说八道”的关键。我们需要精细设计提示词模板。

1
2
3
4
5
6
7
String SYSTEM_PROMPT = """
Use the information from the DOCUMENTS section to provide accurate answers but act as if you knew this information innately.
If unsure, simply state that you don't know.
Another thing you need to note is that your reply must be in Chinese!
DOCUMENTS:
{documents}
""";

提示词设计要点解析

关键指令 作用描述
Use … DOCUMENTS 强制模型仅依据提供的材料回答,减少幻觉。
act as if you knew… 让模型“假装”这些知识是它自带的,使回答语气更自然,而不是机械地复述。
If unsure… 不知道就说不知道,确保回答的严谨性。
reply in Chinese 强制中文输出,防止模型突然飙英文。
{documents} 这是一个动态占位符,用于注入上一步检索到的文本。

步骤 5:流式调用与异常兜底

最后,将组装好的消息(用户问题 + 系统提示词)发送给大模型。

1
2
3
4
5
6
7
// 组装消息
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage(message));
messages.add(new SystemPromptTemplate(SYSTEM_PROMPT).createMessage(Map.of("documents", documentsCollectors)));

// 发起流式调用
return chatClient.stream(new Prompt(messages, options));

兜底策略:

为了保证系统的健壮性,如果向量检索过程(如数据库挂了)发生异常,我们捕获该异常并降级为普通对话模式,确保用户至少能得到一个回复,而不是报错页面。

1
2
3
4
} catch (Exception ignored) {
// 降级:不带知识库直接问大模型
return chatClient.stream(new Prompt(message, options));
}