在 AI 技术蓬勃发展的当下,利用大语言模型和向量数据库构建智能问答系统已成为一种趋势。本文将介绍如何使用 Java 结合 Ollama、DeepSeek 和 Milvus,实现一个基于私有知识库的问答系统,让 DeepSeek 根据构建的私有知识库回答用户问题。
一、项目概述
我们将实现一个系统,该系统能够:
-
使用 Ollama 提供的文本嵌入功能,将文本转换为向量。
-
将这些向量存储在 Milvus 向量数据库中。
-
使用 DeepSeek 模型根据用户问题生成回答。
-
结合 Milvus 中存储的私有知识库,提供更准确的问答服务。
二、环境准备
-
安装并运行 Milvus 服务
-
安装并运行 Ollama 服务器
-
下载DeepSeek及nomic-embed-text
stranger@StrangerdeMacBook-Pro ~ % ollama list
NAME ID SIZE MODIFIED
deepseek-r1:1.5b a42b25d8c10a 1.1 GB 27 hours ago
bge-m3:latest 790764642607 1.2 GB 2 days ago
nomic-embed-text:latest 0a109f422b47 274 MB 2 days ago
deepseek-r1:7b 0a8c26691023 4.7 GB 11 days ago
三、代码实现
1. Milvus 配置
首先,我们需要配置 Milvus 客户端,连接到 Milvus 服务器:
java复制
@Configuration
public class MilvusConfig {
@Value("${milvus.host}")
private String milvusHost;
@Value("${milvus.port}")
private int milvusPort;
@Bean
public MilvusClientV2 milvusClientV2() {
ConnectConfig connectConfig = ConnectConfig.builder()
.uri(String.format("http://%s:%d", milvusHost, milvusPort))
.build();
return new MilvusClientV2(connectConfig);
}
}
在 application.yml
中配置 Milvus 的主机和端口:
yaml复制
milvus:
host: localhost
port: 19530
2. Milvus 服务实现
创建一个服务类 MilvusEmbeddingService
,用于与 Milvus 交互:
java复制
@Service
public class MilvusEmbeddingService {
private static final String COLLECTION_NAME = "user_data";
private static final int VECTOR_DIM = 768;
private final MilvusClientV2 milvusClientV2;
@Autowired
public MilvusEmbeddingService(MilvusClientV2 milvusClientV2) {
this.milvusClientV2 = milvusClientV2;
}
@PostConstruct
public void init() {
createCollection();
}
public void addDocument(String text) {
float[] vector = getEmbedding(text);
JsonObject document = new JsonObject();
document.addProperty("id", UUID.randomUUID().toString());
document.addProperty("text", text);
document.add("vector", getVectorJsonArray(vector));
List<JsonObject> data = new ArrayList<>();
data.add(document);
InsertReq insertReq = InsertReq.builder()
.collectionName(COLLECTION_NAME)
.data(data)
.build();
InsertResp insertResp = milvusClientV2.insert(insertReq);
System.out.println("Insert response: " + insertResp);
}
public List<String> searchDocuments(String queryText, int topK) {
float[] queryVector = getEmbedding(queryText);
SearchReq searchReq = SearchReq.builder()
.collectionName(COLLECTION_NAME)
.data(Collections.singletonList(new FloatVec(queryVector)))
.topK(topK)
.outputFields(Collections.singletonList("text"))
.build();
SearchResp searchResp = milvusClientV2.search(searchReq);
List<String> relatedDocuments = new ArrayList<>();
if (searchResp != null && searchResp.getSearchResults() != null && !searchResp.getSearchResults().isEmpty()) {
for (SearchResp.SearchResult result : searchResp.getSearchResults().get(0)) {
relatedDocuments.add(result.getEntity().get("text").toString());
if (relatedDocuments.size() >= topK) {
break;
}
}
}
return relatedDocuments;
}
private float[] getEmbedding(String text) {
try {
JsonObject requestBody = new JsonObject();
requestBody.addProperty("model", "nomic-embed-text");
requestBody.addProperty("prompt", text);
RequestBody body = RequestBody.create(
MediaType.parse("application/json; charset=utf-8"),
requestBody.toString()
);
Request request = new Request.Builder()
.url("http://localhost:11434/api/embeddings")
.post(body)
.build();
Response response = new OkHttpClient().newCall(request).execute();
if (!response.isSuccessful()) {
System.out.println("Request failed: " + response.code());
return new float[VECTOR_DIM];
}
String responseBody = response.body().string();
JsonObject jsonResponse = new com.google.gson.JsonParser().parse(responseBody).getAsJsonObject();
JsonArray embeddingsArray = jsonResponse.getAsJsonArray("embedding");
if (embeddingsArray == null || embeddingsArray.size() == 0) {
System.out.println("Empty embedding array");
return new float[VECTOR_DIM];
}
float[] vector = new float[embeddingsArray.size()];
for (int i = 0; i < embeddingsArray.size(); i++) {
vector[i] = embeddingsArray.get(i).getAsFloat();
}
return vector;
} catch (IOException e) {
e.printStackTrace();
return new float[VECTOR_DIM];
}
}
private JsonArray getVectorJsonArray(float[] vector) {
JsonArray jsonArray = new JsonArray();
for (float value : vector) {
jsonArray.add(value);
}
return jsonArray;
}
public void createCollection() {
CreateCollectionReq.CollectionSchema schema = milvusClientV2.createSchema();
schema.addField(AddFieldReq.builder()
.fieldName("id")
.dataType(DataType.VarChar)
.isPrimaryKey(true)
.autoID(false)
.build());
schema.addField(AddFieldReq.builder()
.fieldName("text")
.dataType(DataType.VarChar)
.maxLength(1000)
.build());
schema.addField(AddFieldReq.builder()
.fieldName("vector")
.dataType(DataType.FloatVector)
.dimension(VECTOR_DIM)
.build());
IndexParam indexParam = IndexParam.builder()
.fieldName("vector")
.metricType(IndexParam.MetricType.COSINE)
.build();
CreateCollectionReq createCollectionReq = CreateCollectionReq.builder()
.collectionName(COLLECTION_NAME)
.collectionSchema(schema)
.indexParams(Collections.singletonList(indexParam))
.build();
milvusClientV2.createCollection(createCollectionReq);
System.out.println("Collection created successfully");
}
}
3. Ollama 和 DeepSeek 集成
在 Spring Boot 项目中,配置 Ollama 和 DeepSeek 的相关参数:
yaml复制
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: deepseek-r1:1.5b
embedding:
model: nomic-embed-text:latest
4. 控制器实现
创建一个控制器 OllamaController
,用于处理用户请求:
java复制
@RestController
@RequestMapping("/api/ollama")
public class OllamaController {
@Autowired
private MilvusEmbeddingService milvusService;
@Autowired
private OllamaChatModel ollamaChatModel;
@PostMapping(value = "/chat", consumes = MediaType.APPLICATION_JSON_VALUE)
public Flux<String> chatWithContext(@RequestBody Map<String, String> input) {
String queryText = input.get("msg");
// 将自定义文本内容存入向量数据库
String data = getBaseData();
String[] documents = data.split("\n");
for (String doc : documents) {
if (!doc.trim().isEmpty()) {
milvusService.addDocument(doc);
}
}
// 检索相关文档
List<String> relatedDocuments = milvusService.searchDocuments(queryText, 3);
// 生成带有上下文的提示
String prompt = generatePromptWithContext(queryText, relatedDocuments);
// 调用 Ollama API
Flux<String> stream = ollamaChatModel.stream(prompt);
return stream;
}
private String generatePromptWithContext(String userInput, List<String> context) {
StringBuilder promptBuilder = new StringBuilder("以下是与您的问题相关的内容:\n");
for (String doc : context) {
promptBuilder.append(doc).append("\n");
}
promptBuilder.append("现在,请根据上述内容回答:").append(userInput);
return promptBuilder.toString();
}
private String getBaseData() {
return "姓名 年龄 手机号 地址\n" +
"张三 25 13800138000 北京市朝阳区望京SOHO\n" +
"李四 30 13900139000 上海市浦东新区陆家嘴\n" +
"王五 28 13700137000 广州市天河区珠江新城\n" +
"赵六 35 13600136000 深圳市南山区科技园\n" +
"孙七 22 13400134000 成都市武侯区桐梓林\n" +
"周八 26 13500135000 杭州市西湖区文三路\n" +
"吴九 29 13300133000 南京市玄武区珠江路\n" +
"郑十 32 13200132000 武汉市洪山区光谷\n" +
"钱十一 27 13100131000 西安市雁塔区小寨\n" +
"陈十二 31 13000130000 长沙市岳麓区梅溪湖\n" +
"杨十三 24 15900159000 重庆市渝中区解放碑\n" +
"黄十四 23 15800158000 苏州市姑苏区观前街\n" +
"徐十五 33 15700157000 天津市南开区鞍山西道\n" +
"李四 21 15600156000 沈阳市和平区三好街\n" +
"何十七 34 15500155000 大连市中山区人民路\n" +
"郭十八 20 15400154000 青岛市市南区香港中路\n" +
"林十九 36 15300153000 厦门市思明区莲坂\n" +
"马二十 27 15200152000 福州市鼓楼区五四路\n" +
"罗二十一 30 15100151000 合肥市庐阳区三孝口\n" +
"宋二十二 28 15000150000 南昌市东湖区八一大道\n" +
"唐二十三 32 18800188000 昆明市盘龙区北京路\n" +
"刘二十四 26 18700187000 长沙市芙蓉区五一广场\n" +
"白二十五 29 18600186000 贵阳市南明区花果园\n" +
"杜二十六 35 18500185000 南宁市青秀区民族大道\n" +
"冯二十七 23 18400184000 哈尔滨市南岗区学府路\n" +
"李四 24 18300183000 长春市朝阳区红旗街\n" +
"董二十九 33 18200182000 石家庄市长安区中山路\n" +
"彭三十 31 18100181000 郑州市金水区花园路";
}
}
5. 相关依赖
<!-- 识别图片 -->
<dependency>
<groupId>ai.djl.tensorflow</groupId>
<artifactId>tensorflow-model-zoo</artifactId>
<version>0.20.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- HTTP客户端 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
<version>2.5.4</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
6. 配置文件
server:
port: 8080
servlet:
context-path: /deepseek
spring:
application:
name: deepseek
ai:
ollama:
base-url: http://localhost:11434
chat:
model: deepseek-r1:1.5b
embedding:
model: nomic-embed-text:latest
milvus:
host: localhost
port: 19530
collection:
name: user_data
dimension: 768 # Dimension of the embedding vector
index-type: IVF_FLAT
metric-type: L2
四、运行与测试
-
启动 Milvus 服务器和 Ollama 服务器。
-
启动 Spring Boot 应用程序。
-
使用thymeleaf简单做了个页面
-
效果如下:
从代码逻辑和设计来看,存在以下一些问题点, 本文只是简单demo,没做深究:
1. 固定数据的插入问题
-
方法:
getBaseData()
返回固定文本数据,每次调用/api/ollama/chat
接口时,都会调用milvusService.addDocument(doc)
将这些固定数据存入 Milvus。 -
问题:
-
每次请求都会插入相同的数据,导致 Milvus 数据库数据冗余。
-
如果用户多次调用接口,数据库中会包含大量重复数据。
-
数据的 ID 是通过
UUID.randomUUID().toString()
生成的,这不会重复,但 Milvus 集合中会出现大量重复的文本内容。
-
2. 上下文文档数量固定
-
方法:
searchDocuments(queryText, 3)
调用时,总是返回最多 3 条相关文档。 -
问题:
-
3 条文档的限制是硬编码,不能灵活调整。
-
如果用户问题需要更多上下文,或者更少的上下文,这个数字可能不够。
-
需要根据实际情况考虑是否动态调整检索结果的数量。
-
3. 提示字符串的格式问题
-
方法:
generatePromptWithContext()
构造了一个固定的提示字符串格式,包含上下文内容和用户问题。 -
问题:
-
提示的格式是否合理?是否符合 Ollama 模型的预期输入格式?
-
如果上下文内容过长,提示字符串可能会超出模型的输入限制,导致生成失败。
-
4. 流式响应的处理问题
-
方法:
ollamaChatModel.stream(prompt)
返回一个流式响应。 -
问题:
-
流式响应的格式是否在接口文档中定义清楚?
-
客户端是否能够正确处理流式响应?
-
如果 Ollama 模型返回异常数据,流式响应如何处理?
-
5. 向量生成的效率问题
-
方法:
getEmbedding()
每次都需要调用 Ollama API 获取文本的嵌入向量。 -
问题:
-
如果每次处理请求都需要生成大量的向量,效率可能较低。
-
缺少缓存机制,对相同的文本重复生成向量。
-
6. 错误处理和异常日志
-
问题:
-
代码中对异常的处理较为简单,大部分地方直接用
e.printStackTrace()
输出错误。 -
缺少详细的日志记录,不利于问题的排查和调试。
-
7. 资源释放问题
-
问题:
-
使用了 OkHttp 客户端,但在
getEmbedding()
方法中没有明确关闭客户端。 -
OkHttp 的连接可能不会被及时释放,导致资源浪费。
-
8. 配置的硬编码问题
-
问题:
-
Milvus 的集合名称
user_data
是硬编码的,如果需要动态调整,需要修改代码。 -
数据字段的定义(如
id
、text
和vector
)也硬编码在代码中,灵活性较差。
-
9. 数据一致性问题
-
问题:
-
如果在并发环境下,多个请求同时插入或检索数据,是否会导致数据一致性问题?
-
Milvus 的插入和检索操作是否保证原子性?
-
10. 扩展
问答链(Chain)
LangChain框架中定义的结构化处理流程,通过多个组件的链式调用完成知识检索、上下文整合、答案生成等任务
典型链式操作类型(LangChain实现)
- MapReduceDocumentsChain
将文档分块处理后分别分析,再汇总结果,适用于长文本处理。 - RefineDocumentsChain
通过迭代优化逐步精炼答案,提升生成内容准确性。 - StuffDocumentsChain
直接将所有相关文档片段拼接为上下文输入LLM,适合短文本场景。
本文只是简单直接将所有相关文档片段拼接为上下文输入LLM,类似于StuffDocumentsChain
方法 1:使用 Python 的 LangChain 并通过 REST API 调用
2:在 Java 中手动实现问答链逻辑
3:使用替代的 Java 库
虽然 LangChain 没有 Java SDK,但可以使用其他 Java 库和框架:
-
Deeplearning4j:用于深度学习和自然语言处理。
-
Wikipedia Library:用于知识检索和问答。
五、总结
通过结合 Java、Ollama、DeepSeek 和 Milvus,我们成功构建了一个基于私有知识库的问答系统。Milvus 提供了高效的向量存储和检索功能,Ollama 提供了强大的文本嵌入服务,而 DeepSeek 则负责生成自然语言回答。这种组合为构建智能问答系统提供了一个强大的技术栈。
文章只是简单基于文本导入向量库,文件及图片等相关功能。。。。。。
三天后。。。
书接上回:
像DeepSeek、Kimi这样的网页端如何实现识别上传的文件和图片,尤其是它们作为基于文本的语言模型,提到识别Word、PDF等文档,涉及到文档解析技术,需要多模态模型将非文本信息转化为文本,再交给语言模型处理,这里简单扩展一下文件处理
添加依赖
<!-- PDF解析 -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.27</version>
</dependency>
<!-- 支持XML解析 -->
<dependency>
<groupId>org.apache.xmlbeans</groupId>
<artifactId>xmlbeans</artifactId>
<version>5.1.1</version>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.4</version>
</dependency>
<!-- Apache Commons IO -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<!-- Office文档解析 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.2.3</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>5.2.3</version>
</dependency>
1. 文件解析器接口
import java.io.InputStream;
public interface FileParser {
String parse(InputStream is) throws Exception;
}
2. 具体解析器实现
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.poi.hwpf.HWPFDocument;
import java.io.InputStream;
public class DocParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
try (HWPFDocument doc = new HWPFDocument(is)) {
return doc.getDocumentText().replaceAll("\u0007", "");
}
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import java.io.InputStream;
public class DocxParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
try (XWPFDocument doc = new XWPFDocument(is)) {
return new XWPFWordExtractor(doc).getText();
}
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import java.io.InputStream;
public class PdfParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
try (PDDocument doc = PDDocument.load(is)) {
return new PDFTextStripper().getText(doc);
}
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.poi.hslf.usermodel.HSLFSlideShow;
import org.apache.poi.sl.extractor.SlideShowExtractor;
import java.io.InputStream;
public class PptParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
try (HSLFSlideShow slideshow = new HSLFSlideShow(is)) {
SlideShowExtractor extractor = new SlideShowExtractor(slideshow);
return extractor.getText();
}
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFSlide;
import org.apache.poi.xslf.usermodel.XSLFTextShape;
import java.io.InputStream;
import java.util.stream.Collectors;
public class PptxParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
try (XMLSlideShow slideshow = new XMLSlideShow(is)) {
return slideshow.getSlides().stream()
.map(this::extractTextFromSlide)
.collect(Collectors.joining("\n"));
}
}
private String extractTextFromSlide(XSLFSlide slide) {
return slide.getShapes().stream()
.filter(shape -> shape instanceof XSLFTextShape)
.map(shape -> ((XSLFTextShape) shape).getText().trim())
.filter(text -> !text.isEmpty())
.collect(Collectors.joining("\n"));
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.commons.io.IOUtils;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public class TxtParser implements FileParser {
@Override
public String parse(InputStream is) throws IOException {
return IOUtils.toString(is, String.valueOf(StandardCharsets.UTF_8));
}
}
import cn.com.skyvis.milvus.service.FileParser;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Cell;
import java.io.InputStream;
public class XlsxParser implements FileParser {
@Override
public String parse(InputStream is) throws Exception {
StringBuilder sb = new StringBuilder();
try (XSSFWorkbook workbook = new XSSFWorkbook(is)) {
for (Sheet sheet : workbook) {
for (Row row : sheet) {
for (Cell cell : row) {
sb.append(cell.toString()).append("\t");
}
sb.append("\n");
}
}
}
return sb.toString();
}
}
3. 解析器分发器
import cn.com.skyvis.milvus.service.impl.*;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
@Service
public class FileParserDispatcher {
private static final Map<String, FileParser> PARSERS = new HashMap<>();
// 初始化解析器映射
static {
PARSERS.put("pdf", new PdfParser());
PARSERS.put("doc", new DocParser());
PARSERS.put("docx", new DocxParser());
PARSERS.put("xlsx", new XlsxParser());
PARSERS.put("ppt", new PptParser());
PARSERS.put("pptx", new PptxParser());
PARSERS.put("txt", new TxtParser());
}
public String parse(MultipartFile file) throws Exception {
String ext = getFileExtension(file.getOriginalFilename());
FileParser parser = PARSERS.getOrDefault(ext, new TxtParser());
return parser.parse(file.getInputStream());
}
private static String getFileExtension(String filename) {
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}
public static void main(String[] args) {
File file = new File("/Users/stranger/Desktop/未命名.pptx");
String ext = getFileExtension(file.getName());
FileParser parser = PARSERS.getOrDefault(ext, new TxtParser());
try {
InputStream inputStream = new FileInputStream(file);
String parse = null;
try {
parse = parser.parse(inputStream);
} catch (Exception e) {
throw new RuntimeException(e);
}
System.out.println("parse = " + parse);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
}
}
4. SpringBoot控制器
import cn.com.skyvis.milvus.service.FileParserDispatcher;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
@RestController
@RequestMapping("/api/files")
public class FileController {
@Autowired
private FileParserDispatcher dispatcher;
@Autowired
private OllamaChatModel ollamaChatModel;
@PostMapping(value = "/handleFileUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Flux<String> handleFileUpload(
@RequestPart("file") MultipartFile file,
@RequestPart("msg") String queryText) {
try {
// 文件解析
String textContent = dispatcher.parse(file);
// 生成提示模板
String prompt = generatePromptWithContext(queryText, textContent);
// 流式响应
return ollamaChatModel.stream(prompt)
.onErrorResume(e -> Flux.error(
new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"模型服务异常: " + e.getMessage())
));
} catch (Exception e) {
return Flux.error(new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"文件处理失败: " + e.getMessage()
));
}
}
private String generatePromptWithContext(String userInput, String context) {
return String.format("""
以下是与您的问题相关的文件内容:
%s
现在,请根据上述内容回答:%s
""", context, userInput);
}
}
4. 运行结果
以上是解析常用文件;至于图片就比较复杂了,文字类图片还好说,通过OCR即可提取文字,但这只是基于纯文本模型,如果想要识别图片的色彩和构图等元素,即像人类一样感知色彩的明暗对比、形状的组合方式、构图的视觉重心等,则可能需集成视觉模型(如深度学习中的卷积神经网络,CNN)来处理图片的视觉信息。。。。。先鸽