第2221篇:企业级多模态知识库——图文混合内容的检索与问答系统
大约 9 分钟
第2221篇:企业级多模态知识库——图文混合内容的检索与问答系统
适读人群:做知识库、企业搜索、文档问答系统的工程师 | 阅读时长:约18分钟 | 核心价值:构建支持图文混合内容的企业级RAG知识库,解决纯文本知识库无法处理的视觉信息
我们公司的技术文档库里有大量的架构图、流程图、界面截图。
之前的知识库系统只处理文字,每次同事问"XX系统的部署架构是怎样的",系统找到的是一篇文章,但真正的架构信息在那张 Visio 导出的架构图里——系统根本没有处理那张图。
结果是:知识库有价值,但图里的知识「不存在」。
后来我们做了多模态知识库改造,把图片里的信息也纳入检索范围,效果立竿见影。这篇文章把整套工程方案讲清楚。
图文混合知识库的架构设计
这个架构的核心理念是:把图片转化为可检索的文字描述,同时保留原始图片用于回答时引用。
文档摄取:图片内容的语义化处理
/**
* 企业知识库文档摄取服务
* 处理各类文档格式,提取文字和图片内容
*/
@Service
@Slf4j
public class KnowledgeBaseIngestionService {
@Autowired
private DocumentParser documentParser;
@Autowired
private ImageContentAnalyzer imageAnalyzer;
@Autowired
private MultimodalEmbeddingModel embeddingModel;
@Autowired
private VectorStoreService vectorStore;
@Autowired
private DocumentStorageService documentStorage;
/**
* 文档摄取主流程
* 支持 PDF、Word、PPT、HTML、Markdown 等格式
*/
@Async("documentIngestionExecutor")
public CompletableFuture<IngestionResult> ingestDocument(DocumentUpload upload) {
String documentId = UUID.randomUUID().toString();
IngestionResult result = new IngestionResult(documentId);
log.info("开始摄取文档: documentId={}, fileName={}, fileSize={}KB",
documentId, upload.getFileName(),
upload.getFileBytes().length / 1024);
try {
// 1. 解析文档
ParsedDocument parsed = documentParser.parse(upload.getFileBytes(),
upload.getFileType());
// 2. 处理文字内容
List<KnowledgeChunk> textChunks = processTextContent(parsed, documentId);
// 3. 处理图片内容
List<KnowledgeChunk> imageChunks = processImageContent(parsed, documentId);
// 4. 批量写入向量库
List<KnowledgeChunk> allChunks = new ArrayList<>();
allChunks.addAll(textChunks);
allChunks.addAll(imageChunks);
embedAndStore(allChunks);
// 5. 存储原始文档(用于检索后的内容展示)
documentStorage.store(documentId, upload.getFileBytes(),
DocumentMeta.builder()
.fileName(upload.getFileName())
.fileType(upload.getFileType())
.uploadTime(Instant.now())
.textChunkCount(textChunks.size())
.imageChunkCount(imageChunks.size())
.build());
result.setSuccess(true);
result.setTextChunkCount(textChunks.size());
result.setImageChunkCount(imageChunks.size());
log.info("文档摄取完成: documentId={}, textChunks={}, imageChunks={}",
documentId, textChunks.size(), imageChunks.size());
} catch (Exception e) {
log.error("文档摄取失败: documentId={}", documentId, e);
result.setSuccess(false);
result.setErrorMessage(e.getMessage());
}
return CompletableFuture.completedFuture(result);
}
/**
* 文字内容处理:分块 + 语义增强
*/
private List<KnowledgeChunk> processTextContent(ParsedDocument parsed,
String documentId) {
List<KnowledgeChunk> chunks = new ArrayList<>();
for (ParsedPage page : parsed.getPages()) {
String pageText = page.getTextContent();
if (pageText == null || pageText.trim().isEmpty()) continue;
// 智能分块:按语义段落分块,而非固定长度
List<String> splits = semanticChunk(pageText, 500, 50);
for (int i = 0; i < splits.size(); i++) {
String chunkText = splits.get(i);
// 添加上下文标题(改善检索精度)
String enrichedText = enrichWithContext(chunkText, page.getPageTitle(),
page.getSectionPath(), parsed.getDocumentTitle());
chunks.add(KnowledgeChunk.builder()
.chunkId(documentId + "_text_p" + page.getPageNum() + "_" + i)
.documentId(documentId)
.contentType(ContentType.TEXT)
.content(enrichedText)
.originalContent(chunkText)
.pageNum(page.getPageNum())
.sectionPath(page.getSectionPath())
.build());
}
}
return chunks;
}
/**
* 图片内容处理:提取图片并生成语义描述
*/
private List<KnowledgeChunk> processImageContent(ParsedDocument parsed,
String documentId) {
List<KnowledgeChunk> chunks = new ArrayList<>();
for (ParsedPage page : parsed.getPages()) {
for (ExtractedImage image : page.getImages()) {
// 过滤装饰性小图(通常无信息价值)
if (image.getWidth() < 100 || image.getHeight() < 100) continue;
if (image.getFileSizeBytes() < 5000) continue; // 小于5KB,可能是图标
try {
// 用多模态模型生成图片的语义描述
ImageSemanticDescription description =
imageAnalyzer.analyzeForKnowledgeBase(
image.getImageBytes(),
page.getPageTitle(),
page.getSurroundingText()
);
// 存储图片到对象存储
String imageStorageKey = documentStorage.storeImage(
documentId, image.getImageBytes(), image.getImageIndex());
KnowledgeChunk imageChunk = KnowledgeChunk.builder()
.chunkId(documentId + "_img_p" + page.getPageNum()
+ "_" + image.getImageIndex())
.documentId(documentId)
.contentType(ContentType.IMAGE)
.content(description.getSearchableText()) // 用于向量检索
.imageStorageKey(imageStorageKey) // 指向原始图片
.imageType(description.getImageType()) // 架构图/流程图/截图等
.pageNum(page.getPageNum())
.sectionPath(page.getSectionPath())
.imageDescription(description.getDetailedDescription())
.build();
chunks.add(imageChunk);
} catch (Exception e) {
log.warn("图片分析失败,跳过: documentId={}, page={}, imageIndex={}",
documentId, page.getPageNum(), image.getImageIndex(), e);
}
}
}
return chunks;
}
/**
* 为图片知识块生成专门的语义描述
*/
@Service
public static class ImageContentAnalyzer {
@Autowired
private OpenAiClient openAiClient;
public ImageSemanticDescription analyzeForKnowledgeBase(
byte[] imageBytes, String pageTitle, String surroundingText) {
String base64 = Base64.getEncoder().encodeToString(imageBytes);
String analysisPrompt = String.format("""
这张图片来自企业技术文档,页面标题是:"%s"
图片周围的文字是:"%s"
请分析这张图片并提供:
1. 图片类型(从以下选择):
- 系统架构图
- 流程图/泳道图
- 界面截图/UI设计图
- 数据图表(折线图/柱状图/饼图)
- 实体关系图/ER图
- 代码截图
- 示意图/原理图
- 其他
2. 详细描述(150-300字):描述图片的完整内容和技术含义
3. 关键词列表:提取5-10个用于检索的关键词
4. 可检索摘要(50-80字):用于向量检索的简洁文字描述
JSON格式输出:
{
"imageType": "类型",
"detailedDescription": "详细描述",
"keywords": ["关键词1", ...],
"searchableText": "可检索摘要"
}
""", pageTitle, surroundingText.substring(0,
Math.min(200, surroundingText.length())));
String response = openAiClient.chatMultimodal(
analysisPrompt, base64, "image/jpeg",
ChatOptions.builder().temperature(0.1).maxTokens(500).build()
);
try {
String cleaned = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").trim();
JsonNode node = new ObjectMapper().readTree(cleaned);
return ImageSemanticDescription.builder()
.imageType(node.get("imageType").asText())
.detailedDescription(node.get("detailedDescription").asText())
.keywords(extractStringList(node.get("keywords")))
.searchableText(node.get("searchableText").asText())
.build();
} catch (Exception e) {
log.error("图片描述解析失败", e);
return ImageSemanticDescription.fallback(pageTitle);
}
}
private List<String> extractStringList(JsonNode node) {
List<String> list = new ArrayList<>();
if (node != null && node.isArray()) {
node.forEach(n -> list.add(n.asText()));
}
return list;
}
}
private String enrichWithContext(String chunkText, String pageTitle,
String sectionPath, String docTitle) {
StringBuilder enriched = new StringBuilder();
if (docTitle != null && !docTitle.isEmpty()) {
enriched.append("文档:").append(docTitle).append(" > ");
}
if (sectionPath != null && !sectionPath.isEmpty()) {
enriched.append(sectionPath).append(" > ");
}
if (pageTitle != null && !pageTitle.isEmpty()) {
enriched.append(pageTitle).append("\n");
}
enriched.append(chunkText);
return enriched.toString();
}
private List<String> semanticChunk(String text, int maxSize, int overlap) {
List<String> chunks = new ArrayList<>();
String[] paragraphs = text.split("\n\n+");
StringBuilder current = new StringBuilder();
for (String para : paragraphs) {
if (current.length() + para.length() > maxSize && current.length() > 0) {
chunks.add(current.toString().trim());
int overlapStart = Math.max(0, current.length() - overlap);
current = new StringBuilder(current.substring(overlapStart));
}
current.append(para).append("\n\n");
}
if (current.length() > 0) chunks.add(current.toString().trim());
return chunks;
}
private void embedAndStore(List<KnowledgeChunk> chunks) {
List<List<KnowledgeChunk>> batches = partitionList(chunks, 20);
for (List<KnowledgeChunk> batch : batches) {
List<String> texts = batch.stream()
.map(KnowledgeChunk::getContent)
.collect(Collectors.toList());
List<float[]> embeddings = embeddingModel.batchEmbedText(texts);
for (int i = 0; i < batch.size(); i++) {
batch.get(i).setEmbedding(embeddings.get(i));
}
vectorStore.upsertBatch(batch);
}
}
private <T> List<List<T>> partitionList(List<T> list, int size) {
List<List<T>> parts = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
parts.add(list.subList(i, Math.min(i + size, list.size())));
}
return parts;
}
}多模态检索:文字查询检索图片
/**
* 多模态知识库检索服务
* 支持文字查询检索文字和图片内容
*/
@Service
@Slf4j
public class MultimodalKnowledgeRetriever {
@Autowired
private VectorStoreService vectorStore;
@Autowired
private MultimodalEmbeddingModel embeddingModel;
@Autowired
private DocumentStorageService documentStorage;
/**
* 混合检索:同时检索文字块和图片块
* 根据查询内容动态调整文字/图片的权重
*/
public List<RetrievedContent> hybridRetrieve(String userQuery, int topK) {
float[] queryVector = embeddingModel.embedText(userQuery);
// 检索文字内容(权重0.6)
List<KnowledgeChunk> textResults = vectorStore.search(
queryVector, topK * 2, // 检索更多候选
VectorSearchFilter.contentType(ContentType.TEXT));
// 检索图片内容(权重0.4)
List<KnowledgeChunk> imageResults = vectorStore.search(
queryVector, topK,
VectorSearchFilter.contentType(ContentType.IMAGE));
// 分析查询意图,动态调整融合权重
QueryIntent intent = analyzeQueryIntent(userQuery);
double textWeight = intent.isVisualQuery() ? 0.4 : 0.6;
double imageWeight = 1.0 - textWeight;
// 融合并重排序
List<RetrievedContent> merged = mergeAndRerank(
textResults, imageResults, queryVector, textWeight, imageWeight, topK);
// 加载图片内容(原始图片字节)
for (RetrievedContent content : merged) {
if (content.getChunk().getContentType() == ContentType.IMAGE) {
byte[] imageBytes = documentStorage.loadImage(
content.getChunk().getImageStorageKey());
content.setImageBytes(imageBytes);
}
}
return merged;
}
/**
* 判断查询是否倾向于视觉内容
*/
private QueryIntent analyzeQueryIntent(String query) {
String lowerQuery = query.toLowerCase();
// 视觉意图关键词
boolean isVisual = Stream.of(
"架构图", "流程图", "示意图", "截图", "界面", "设计图",
"diagram", "screenshot", "architecture", "flow", "chart",
"画了什么", "图中", "图片显示", "看起来", "长什么样"
).anyMatch(lowerQuery::contains);
return QueryIntent.builder().isVisualQuery(isVisual).build();
}
private List<RetrievedContent> mergeAndRerank(
List<KnowledgeChunk> textChunks,
List<KnowledgeChunk> imageChunks,
float[] queryVector,
double textWeight, double imageWeight,
int topK) {
List<RetrievedContent> candidates = new ArrayList<>();
for (KnowledgeChunk chunk : textChunks) {
double score = computeScore(chunk, queryVector) * textWeight;
candidates.add(RetrievedContent.of(chunk, score));
}
for (KnowledgeChunk chunk : imageChunks) {
double score = computeScore(chunk, queryVector) * imageWeight;
candidates.add(RetrievedContent.of(chunk, score));
}
// 去重(同一文档片段不重复返回)
Map<String, RetrievedContent> deduped = new LinkedHashMap<>();
for (RetrievedContent rc : candidates) {
String key = rc.getChunk().getChunkId();
if (!deduped.containsKey(key) ||
deduped.get(key).getScore() < rc.getScore()) {
deduped.put(key, rc);
}
}
return deduped.values().stream()
.sorted(Comparator.comparingDouble(RetrievedContent::getScore).reversed())
.limit(topK)
.collect(Collectors.toList());
}
private double computeScore(KnowledgeChunk chunk, float[] queryVector) {
return cosineSimilarity(queryVector, chunk.getEmbedding());
}
private double cosineSimilarity(float[] v1, float[] v2) {
double dot = 0, norm1 = 0, norm2 = 0;
for (int i = 0; i < v1.length; i++) {
dot += v1[i] * v2[i];
norm1 += v1[i] * v1[i];
norm2 += v2[i] * v2[i];
}
return norm1 == 0 || norm2 == 0 ? 0 : dot / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
}多模态 RAG 生成
/**
* 多模态 RAG 问答生成器
* 基于检索到的图文内容生成回答,并引用来源
*/
@Service
@Slf4j
public class MultimodalRagGenerator {
@Autowired
private MultimodalKnowledgeRetriever retriever;
@Autowired
private OpenAiClient openAiClient;
/**
* 多模态知识库问答主入口
*/
public KnowledgeQaResult answer(String userQuestion) {
// 1. 检索相关内容
List<RetrievedContent> retrieved = retriever.hybridRetrieve(userQuestion, 8);
if (retrieved.isEmpty()) {
return KnowledgeQaResult.noAnswer("知识库中未找到相关内容");
}
// 2. 构建多模态上下文
List<Message> messages = buildMultimodalContext(userQuestion, retrieved);
// 3. 生成回答
String answer = openAiClient.chatWithMessages(messages,
ChatOptions.builder().model("gpt-4o").maxTokens(1000).build());
// 4. 整理来源
List<ContentSource> sources = extracted sources from retrieved;
return KnowledgeQaResult.builder()
.answer(answer)
.sources(sources)
.retrievedCount(retrieved.size())
.hasImageReferences(retrieved.stream()
.anyMatch(r -> r.getChunk().getContentType() == ContentType.IMAGE))
.build();
}
/**
* 构建包含文字和图片的多模态上下文
*/
private List<Message> buildMultimodalContext(String question,
List<RetrievedContent> retrieved) {
// 系统提示
String systemPrompt = """
你是企业知识库助手。基于提供的参考内容(文字和图片)回答问题。
规则:
1. 只使用提供的参考内容回答,不要添加外部知识
2. 回答时注明来源("根据[来源X]...")
3. 如果参考内容包含相关图表,在回答中描述其含义
4. 如果内容不足以回答,直接说明
""";
// 构建用户消息(包含文字和图片)
List<MessagePart> userParts = new ArrayList<>();
// 先添加文字参考内容
StringBuilder textContext = new StringBuilder("【参考内容】\n\n");
int sourceIndex = 1;
for (RetrievedContent rc : retrieved) {
if (rc.getChunk().getContentType() == ContentType.TEXT) {
textContext.append(String.format("[来源%d] 文档:%s(第%d页)\n%s\n\n",
sourceIndex++,
rc.getChunk().getDocumentName(),
rc.getChunk().getPageNum(),
rc.getChunk().getOriginalContent()));
}
}
textContext.append("【问题】\n").append(question);
userParts.add(TextPart.of(textContext.toString()));
// 添加图片参考内容(如有)
int imageIndex = 1;
for (RetrievedContent rc : retrieved) {
if (rc.getChunk().getContentType() == ContentType.IMAGE
&& rc.getImageBytes() != null) {
userParts.add(TextPart.of(String.format(
"\n[图片%d] 来源:%s(第%d页),描述:%s",
imageIndex++,
rc.getChunk().getDocumentName(),
rc.getChunk().getPageNum(),
rc.getChunk().getImageDescription()
)));
String base64 = Base64.getEncoder().encodeToString(rc.getImageBytes());
userParts.add(ImagePart.ofBase64(base64, "image/jpeg"));
}
}
return Arrays.asList(
SystemMessage.of(systemPrompt),
UserMessage.ofParts(userParts)
);
}
}知识库的增量更新与版本管理
/**
* 知识库版本管理
* 支持文档更新时的增量处理和旧版本处理
*/
@Service
@Slf4j
public class KnowledgeBaseVersionManager {
@Autowired
private DocumentMetaRepository metaRepository;
@Autowired
private VectorStoreService vectorStore;
/**
* 文档更新处理
* 删除旧版本向量,摄取新版本
*/
@Transactional
public UpdateResult updateDocument(String existingDocumentId,
DocumentUpload newVersion) {
DocumentMeta existing = metaRepository.findById(existingDocumentId)
.orElseThrow(() -> new DocumentNotFoundException(existingDocumentId));
// 1. 标记旧版本为过期(不立即删除,支持回滚)
existing.setStatus(DocumentStatus.SUPERSEDED);
existing.setSupersededAt(Instant.now());
metaRepository.save(existing);
// 2. 删除旧版本的向量(释放存储)
vectorStore.deleteByDocumentId(existingDocumentId);
// 3. 摄取新版本
// (调用 KnowledgeBaseIngestionService.ingestDocument)
log.info("文档更新完成: oldId={}, 已删除旧向量", existingDocumentId);
return UpdateResult.success();
}
/**
* 定期清理过期文档
*/
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点
public void cleanupExpiredDocuments() {
Instant thirtyDaysAgo = Instant.now().minus(Duration.ofDays(30));
List<DocumentMeta> expired = metaRepository
.findByStatusAndSupersededAtBefore(DocumentStatus.SUPERSEDED, thirtyDaysAgo);
for (DocumentMeta doc : expired) {
documentStorage.delete(doc.getDocumentId());
metaRepository.delete(doc);
log.info("清理过期文档: {}", doc.getDocumentId());
}
log.info("过期文档清理完成: 清理{}个", expired.size());
}
@Autowired
private DocumentStorageService documentStorage;
}部署后的监控指标
多模态知识库上线后,持续关注这些指标:
- 检索命中率:用户提问后有多少比例找到了相关内容
- 图片引用率:最终回答中有多少次引用了图片内容(反映图片知识库的价值)
- 用户满意度:通过点赞/点踩收集反馈
- 图片分析失败率:图片内容理解失败的比例(高了要优化分析策略)
- 平均检索延迟:端到端响应时间
实践数据:我们的技术文档知识库,加入图片处理后,用户满意度从 68% 提升到 84%,主要收益来自架构图和流程图相关问题的准确回答。
