创建一个关于智能博物馆导览案例,使用RAG Pipeline,包含:
- 多向量集合(文本/图像/稀疏向量)
- 混合检索(语义 + 关键词),带元数据过滤
- 二阶段重排(向量相似度 → LLM / 交叉编码器重排)
- Retriever + Chain 结构化流水线(可插拔)
- 工具调用(Function-Calling):自动生成"展厅路线规划"
- SSE 流式回答 + 反馈闭环(用户点赞/点踩回写权重)
下面代码以 Spring Boot + Spring AI + Qdrant 为基础的架构。
1) 依赖(Gradle)
gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// Spring AI(按你本地版本替换;以下为占位范例)
implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:0.8.1'
implementation 'org.springframework.ai:spring-ai-qdrant-store-spring-boot-starter:0.8.1'
// 可选:日志 & JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'org.slf4j:slf4j-api:2.0.13'
// 可选:重排器(若用 MiniLM 交叉编码器可走 Java 推理引擎或 JNI;示例里给 LLM 重排)
}
2) 配置(application.yml)
yaml
server:
port: 8080
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
temperature: 0.2
embedding:
options:
model: text-embedding-3-large # 文本向量
qdrant:
host: ${QDRANT_HOST:localhost}
port: ${QDRANT_PORT:6333}
# 如果是 Cloud,配置 api-key / https
api-key: ${QDRANT_API_KEY:}
https: ${QDRANT_HTTPS:false}
app:
# 自定义:图像向量模型(示例:走自定义客户端)
image-embed:
dim: 512
retrieval:
topK: 12
rerankK: 6
minScore: 0.15
3) Qdrant 集合设计(多向量 + 稀疏向量)
- collections :
exhibits
- 向量槽位 (named vectors):
text_vec
(维度 3072,对应text-embedding-3-large
)image_vec
(维度 512,自定义图像模型)sparse_vec
(稀疏向量;用于关键词/倒排混合检索)
- payload (元数据):
exhibitId
,title
,era
,region
,room
,tags[]
,imageUrl
,coords: {x,y,floor}
,source
,lang
说明:Qdrant 原生支持命名向量 与稀疏向量(可与 dense 混合评分)。这让"文本/图片/关键词"统一到一个集合中。
初始化集合(示例 Bean)
java
@Configuration
public class QdrantInitConfig {
@Bean
CommandLineRunner initQdrantCollection(QdrantVectorStore store) {
return args -> {
// 若你的 spring-ai-qdrant 已封装集合创建,可忽略
// 否则可用 Qdrant HTTP 客户端初始化:
// 1) 创建 exhibits 集合,含 named vectors: text_vec, image_vec, sparse_vec
// 2) 设置 HNSW / Quantization / Optimizers(示意)
// 这里仅留注释提示:实际项目用官方 Qdrant Java SDK/HTTP 请求创建集合 schema。
};
}
}
4) 自定义图像向量客户端(EmbeddingClient)
Spring AI 的
EmbeddingClient
是个接口。我们实现一个图像版(可对接你偏好的多模态模型:OpenCLIP、本地模型、或推理服务)。
java
import org.springframework.ai.embedding.EmbeddingClient;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.embedding.Embedding;
import org.springframework.stereotype.Component;
@Component
public class ImageEmbeddingClient implements EmbeddingClient {
private final int dim;
public ImageEmbeddingClient(@Value("${app.image-embed.dim}") int dim) {
this.dim = dim;
}
// 针对图像:入参通常是字节数组;这里复用 String 接口时做 Base64 包装或提供重载方法
public EmbeddingResponse embed(byte[] imageBytes) {
// 调用你的图像向量服务 / 本地推理 → 返回 double[] 向量
double[] vec = callYourImageModel(imageBytes); // 维度 dim
return new EmbeddingResponse(List.of(new Embedding(vec)));
}
@Override
public EmbeddingResponse embed(String text) {
// 不支持文本;防误用
throw new UnsupportedOperationException("ImageEmbeddingClient only accepts image bytes");
}
private double[] callYourImageModel(byte[] imageBytes) {
// TODO: 替换为真实模型调用
double[] v = new double[dim];
for (int i = 0; i < dim; i++) v[i] = Math.sin((imageBytes.length + i) * 0.001); // 占位
return v;
}
}
5) 稀疏向量构造(关键词混合)
可用 BM25/BM42 或 SPLADE 生成稀疏向量;此处示意:用简单 TF-IDF 近似(你也可以接入服务化的稀疏编码)。
java
@Component
public class SparseVectorizer {
// 真实项目:使用可重复词表 & idf 统计,这里仅放示意
public Map<Integer, Float> toSparse(String text) {
// 返回 {tokenId -> weight}
// 你可将 tokenizer & 词表固定化,确保在线/离线一致
return Map.of(1, 0.9f, 42, 0.3f); // 占位
}
}
6) 文档建模 & 批量入库(多向量)
java
@Data
@AllArgsConstructor
@NoArgsConstructor
class ExhibitDoc {
String exhibitId;
String title;
String description; // 用于 text_vec
String era; // 如 "Ancient Egypt"
String region; // 地理区域
String room; // 展厅/房间号
List<String> tags;
String imageUrl;
Map<String, Object> coords; // {x,y,floor}
String source; // 数据来源
String lang; // "zh" / "en"
byte[] imageBytes; // 可空
}
@Service
@RequiredArgsConstructor
public class ExhibitIngestService {
private final QdrantVectorStore qdrant;
private final EmbeddingClient textEmbeddingClient; // OpenAI 文本
private final ImageEmbeddingClient imageEmbeddingClient; // 自定义图像
private final SparseVectorizer sparseVectorizer;
public void upsertBatch(List<ExhibitDoc> items) {
for (ExhibitDoc d : items) {
// 1) 文本向量
var textEmb = textEmbeddingClient.embed(d.getDescription()).getData().get(0).getEmbedding();
// 2) 图像向量(可选)
double[] imgEmb = null;
if (d.getImageBytes() != null) {
imgEmb = imageEmbeddingClient.embed(d.getImageBytes()).getData().get(0).getEmbedding();
}
// 3) 稀疏向量
var sparse = sparseVectorizer.toSparse(d.getDescription());
// 4) 组装 payload
Map<String, Object> payload = new HashMap<>();
payload.put("exhibitId", d.getExhibitId());
payload.put("title", d.getTitle());
payload.put("era", d.getEra());
payload.put("region", d.getRegion());
payload.put("room", d.getRoom());
payload.put("tags", d.getTags());
payload.put("imageUrl", d.getImageUrl());
payload.put("coords", d.getCoords());
payload.put("source", d.getSource());
payload.put("lang", d.getLang());
payload.put("content", d.getDescription());
// 5) 写入 Qdrant(命名向量)
qdrant.addNamedVectors(
Map.of(
"text_vec", textEmb,
"image_vec", imgEmb // 允许为 null,store 实现需允许可选
),
sparse, // 稀疏
d.getExhibitId(), // pointId
payload
);
}
}
}
说明 :
addNamedVectors(...)
为示意方法名。实际以你使用的spring-ai-qdrant
版本 API 为准(通常会支持Vector
,Payload
组合写入;若不支持命名向量的快捷方法,则使用底层客户端调用)。
7) 检索器(Retriever):混合检索 + 过滤 + 二阶段重排
java
@Data
@AllArgsConstructor
class RetrievalRequest {
String queryText; // 文本查询
byte[] queryImageBytes; // 图像查询
String lang; // "zh" / "en"
String regionFilter; // 过滤项示例
String eraFilter; // 过滤项示例
int topK;
}
@Data
@AllArgsConstructor
class Retrieved {
String exhibitId;
String title;
String snippet;
double score;
Map<String, Object> payload;
}
@Service
@RequiredArgsConstructor
public class HybridRetriever {
private final QdrantVectorStore qdrant;
private final EmbeddingClient textEmbeddingClient;
private final ImageEmbeddingClient imageEmbeddingClient;
@Value("${app.retrieval.topK}") private int defaultTopK;
@Value("${app.retrieval.minScore}") private double minScore;
public List<Retrieved> retrieve(RetrievalRequest req) {
int k = req.getTopK() > 0 ? req.getTopK() : defaultTopK;
double[] textVec = null;
double[] imgVec = null;
if (req.getQueryText() != null && !req.getQueryText().isBlank()) {
textVec = textEmbeddingClient.embed(req.getQueryText()).getData().get(0).getEmbedding();
}
if (req.getQueryImageBytes() != null) {
imgVec = imageEmbeddingClient.embed(req.getQueryImageBytes()).getData().get(0).getEmbedding();
}
// 元数据过滤
Map<String, Object> filter = new HashMap<>();
if (req.getLang() != null) filter.put("lang", req.getLang());
if (req.getRegionFilter() != null) filter.put("region", req.getRegionFilter());
if (req.getEraFilter() != null) filter.put("era", req.getEraFilter());
// 混合评分策略(示意):text_vec 与 image_vec 同时检索并合并去重;也可用 Qdrant 的 "fusion" 策略或 scalar mix
List<Retrieved> candidates = new ArrayList<>();
if (textVec != null) {
candidates.addAll(
qdrant.similaritySearchNamed("text_vec", textVec, k, filter).stream()
.map(r -> toRetrieved(r))
.toList()
);
// 同时触发一个稀疏检索(关键词),再合并(同 exhibitId 提升分数)
candidates = fuseWithSparse(candidates, req.getQueryText(), k, filter);
}
if (imgVec != null) {
candidates.addAll(
qdrant.similaritySearchNamed("image_vec", imgVec, k, filter).stream()
.map(r -> toRetrieved(r))
.toList()
);
}
// 合并去重(按 exhibitId)
Map<String, Retrieved> best = new HashMap<>();
for (Retrieved r : candidates) {
best.merge(r.getExhibitId(), r, (a, b) -> a.getScore() >= b.getScore() ? a : b);
}
return best.values().stream()
.filter(r -> r.getScore() >= minScore)
.sorted(Comparator.comparingDouble(Retrieved::getScore).reversed())
.limit(k)
.toList();
}
private Retrieved toRetrieved(QdrantSearchResult r) {
Map<String, Object> p = r.getPayload();
return new Retrieved(
(String)p.get("exhibitId"),
(String)p.get("title"),
((String)p.getOrDefault("content","")).length() > 160
? ((String)p.get("content")).substring(0, 160) + "..."
: (String)p.getOrDefault("content",""),
r.getScore(),
p
);
}
private List<Retrieved> fuseWithSparse(List<Retrieved> base, String query, int k, Map<String,Object> filter) {
var sparseQuery = /* 你的稀疏向量化 */ Map.of(1, 0.9f);
var sparseHits = qdrant.sparseSearch("sparse_vec", sparseQuery, k, filter).stream()
.map(this::toRetrieved)
.toList();
// 简单融合策略:相同 exhibitId 的分数相加 / 归一(示意)
Map<String, Retrieved> m = new HashMap<>();
for (Retrieved r : base) m.put(r.getExhibitId(), r);
for (Retrieved s : sparseHits) {
m.merge(s.getExhibitId(), s, (a,b) -> {
a.setScore(a.getScore() + b.getScore()*0.6); // 稀疏加权
return a;
});
}
return new ArrayList<>(m.values());
}
}
上面用到的
similaritySearchNamed(...)
/sparseSearch(...)
/QdrantSearchResult
为示意 API 。你可以用已存在的VectorStore.similaritySearch
并在filter
与named vectors
处自行适配(或直接使用 Qdrant 官方 SDK/HTTP)。
8) 二阶段重排(LLM Rerank / Cross-Encoder)
先取 topK,再做 rerankK 的重排,得到更精确的上下文顺序。
java
@Service
@RequiredArgsConstructor
public class Reranker {
private final ChatClient chatClient;
@Value("${app.retrieval.rerankK}") private int rerankK;
public List<Retrieved> rerankByLLM(String query, List<Retrieved> hits) {
var top = hits.stream().limit(rerankK).toList();
// 构造一个短 prompt 让 LLM 打分(0~1),也可用工具调用返回 JSON
String ctx = top.stream()
.map(r -> String.format("ID:%s\nTitle:%s\nText:%s\n", r.getExhibitId(), r.getTitle(), r.getSnippet()))
.collect(Collectors.joining("\n----\n"));
String prompt = """
你是重排器。请对下面候选文档与用户查询的相关性进行0~1评分,只返回JSON数组:
查询:%s
候选:
%s
输出格式:[{"id":"...","score":0.93},...]
""".formatted(query, ctx);
String json = chatClient.prompt(p -> p.user(prompt)).call().content();
// 解析 JSON 并重排(示例)
Map<String, Double> scores = parseJsonScores(json);
return top.stream()
.map(r -> { r.setScore( 0.5*r.getScore() + 0.5*scores.getOrDefault(r.getExhibitId(), 0.0)); return r; })
.sorted(Comparator.comparingDouble(Retrieved::getScore).reversed())
.collect(Collectors.toList());
}
private Map<String, Double> parseJsonScores(String json) {
// TODO: Jackson 解析
return new HashMap<>();
}
}
9) 工具调用(Function-Calling):展厅路线规划
LLM 识别需要路线 → 调用
planRoute(exhibitIds)
,返回分步指引(结合coords
)。
java
@Data
@AllArgsConstructor
class RouteStep { String room; String note; }
@Data
@AllArgsConstructor
class RoutePlan { List<RouteStep> steps; double totalDistance; }
@Component
public class RouteTool {
public RoutePlan planRoute(List<Map<String,Object>> exhibitPayloads) {
// 根据 coords:{x,y,floor} 做最短路/楼层优先等策略,这里示意:
List<RouteStep> steps = new ArrayList<>();
for (Map<String,Object> p : exhibitPayloads) {
steps.add(new RouteStep((String)p.get("room"), "前往此展品,欣赏后继续"));
}
return new RoutePlan(steps, steps.size() * 42.0);
}
}
将工具暴露为 ChatClient 的可调用函数(按 Spring AI 版本不同,Function/Tool 的注册方式略有差异,示意如下):
java
@Configuration
@RequiredArgsConstructor
public class ChatToolsConfig {
private final RouteTool routeTool;
@Bean
public ChatClient chatClientWithTools(ChatClient.Builder builder) {
return builder
.defaultFunction("planRoute", args -> {
// args: [{"payload": {...}}, ...]
@SuppressWarnings("unchecked")
List<Map<String,Object>> payloads = (List<Map<String,Object>>) args.get("payloads");
var plan = routeTool.planRoute(payloads);
// 返回给 LLM 的 JSON
return Map.of(
"steps", plan.getSteps(),
"totalDistance", plan.getTotalDistance()
);
})
.build();
}
}
10) RAG Chain:Router → Retriever → Rerank → Prompt 组装 → Chat
java
@Service
@RequiredArgsConstructor
public class RAGService {
private final HybridRetriever retriever;
private final Reranker reranker;
private final ChatClient chat;
public String answer(RetrievalRequest req) {
// 1) 路由(文本 / 图像 / 混合)
boolean isImage = req.getQueryImageBytes() != null && (req.getQueryText() == null || req.getQueryText().isBlank());
String userQuery = isImage ? "识别这张图片中的展品,并推荐相关内容" : req.getQueryText();
// 2) 检索(混合)
var hits = retriever.retrieve(req);
// 3) 二阶段重排
var reranked = reranker.rerankByLLM(userQuery, hits);
// 4) 组织上下文(控制 Token)
String context = reranked.stream().limit(5)
.map(r -> "- [" + r.getTitle() + "] " + r.getSnippet())
.collect(Collectors.joining("\n"));
// 5) 触发工具调用(当用户提及"路线"、"怎么走"等,模型可自行决定调用)
String sys = """
你是博物馆导览助手。使用提供的"相关展品"作为答案依据,若用户希望"规划参观路线/顺序",可调用工具 planRoute,并把候选展品的 payload 传给它。
回答需简洁、分步骤,尽量包含房间号和楼层信息。
""";
var msg = chat.prompt()
.system(sys)
.user("""
用户问题:%s
相关展品(候选,不要原样输出,可综合说明):
%s
如需生成参观路线,请调用 planRoute 工具。
""".formatted(userQuery, context))
.call();
return msg.content();
}
}
11) API:查询 & 流式 SSE & 反馈闭环
java
@RestController
@RequestMapping("/api/rag")
@RequiredArgsConstructor
public class RAGController {
private final RAGService ragService;
private final FeedbackService feedbackService;
@PostMapping("/ask")
public String ask(@RequestBody AskRequest req) {
var rr = new RetrievalRequest(
req.getQuestion(),
null,
req.getLang(),
req.getRegion(),
req.getEra(),
req.getTopK()
);
return ragService.answer(rr);
}
@PostMapping(value = "/ask/stream", produces = "text/event-stream")
public SseEmitter askStream(@RequestBody AskRequest req) {
SseEmitter emitter = new SseEmitter(0L);
new Thread(() -> {
try {
// 这里可改成 chat.stream(),逐 token 写出(具体 API 视 Spring AI 版本)
String full = ragService.answer(new RetrievalRequest(
req.getQuestion(), null, req.getLang(), req.getRegion(), req.getEra(), req.getTopK()
));
emitter.send(SseEmitter.event().data(full));
emitter.complete();
} catch (Exception e) {
try { emitter.send(SseEmitter.event().data("[error] " + e.getMessage())); } catch (Exception ignored) {}
emitter.completeWithError(e);
}
}).start();
return emitter;
}
@PostMapping("/feedback")
public void feedback(@RequestBody Feedback fb) {
feedbackService.record(fb);
}
}
@Data class AskRequest {
String question;
String lang;
String region;
String era;
int topK;
}
@Data class Feedback {
String exhibitId;
boolean upvote; // 点赞/点踩
Double boost; // 可选:手动权重
}
反馈写回(简单实现:把权重写入 payload 或 side table,下次检索加分):
java
@Service
public class FeedbackService {
private final QdrantVectorStore qdrant;
public void record(Feedback fb) {
// 方式1:在 Qdrant payload 里存一个 popularity/boost,按 upvote 调整
// 方式2:外部 KV 表维护,检索后融合(更灵活)
// 这里示意:直接调底层更新 payload
Map<String,Object> delta = Map.of("boost", fb.getBoost() != null ? fb.getBoost() : (fb.isUpvote()? 0.2 : -0.2));
qdrant.updatePayload("exhibits", fb.getExhibitId(), delta);
}
}
12) 一键演示:数据导入
java
@Component
@RequiredArgsConstructor
public class DemoDataLoader implements CommandLineRunner {
private final ExhibitIngestService ingest;
@Override
public void run(String... args) throws Exception {
List<ExhibitDoc> docs = List.of(
new ExhibitDoc(
"ex1", "图坦卡蒙黄金面具",
"来自古埃及新王国时期的葬礼面具,象征王权与日神之眼......",
"Ancient Egypt","Africa","Room-EG-1",
List.of("mask","gold","pharaoh"),
"https://example.com/mask.jpg",
Map.of("x", 12, "y", 5, "floor", 2),
"curation-db","zh",
loadImageBytes("/data/mask.jpg")
),
new ExhibitDoc(
"ex2","宙斯雕像",
"古希腊青铜雕像,展现神祇的力量与理想人体比例......",
"Classical Greece","Europe","Room-GR-2",
List.of("bronze","myth"),
"https://example.com/zeus.jpg",
Map.of("x", 3, "y", 18, "floor", 2),
"curation-db","zh",
loadImageBytes("/data/zeus.jpg")
),
new ExhibitDoc(
"ex3","罗塞塔风格象形文字石碑",
"记录宗教仪式与祭祀条目,关键线索帮助破译象形文字......",
"Ancient Egypt","Africa","Room-EG-3",
List.of("stela","hieroglyph"),
"https://example.com/stone.jpg",
Map.of("x", 20, "y", 7, "floor", 2),
"curation-db","zh",
loadImageBytes("/data/stone.jpg")
)
);
ingest.upsertBatch(docs);
}
private byte[] loadImageBytes(String path) throws IOException {
try (var in = getClass().getResourceAsStream(path)) {
return in != null ? in.readAllBytes() : null;
}
}
}
"亮点"一览
- ✅ 多向量集合 :
text_vec
+image_vec
+sparse_vec
- ✅ 混合检索:dense + sparse 融合、元数据过滤
- ✅ 二阶段重排:用 LLM 打分(也可换交叉编码器)
- ✅ 工具调用:自动生成"参观路线"
- ✅ 可扩展链路:Router → Retriever → Rerank → Prompt → Tools
- ✅ SSE 流式输出 & 反馈闭环(随用随学)
可继续加码
- 引入多语言检索:入库双语 embedding + 查询时语言识别与翻译。
- 安全护栏:对 LLM 输出做规则校验(房间号必须存在等)。
- 向量量化/压缩:Qdrant Scalar Quantization/产品级 HNSW 参数调优。
- 可观测性:记录检索分数、Prompt、工具调用参数,接入 OpenTelemetry。
- 评测:构造一批 Q&A(含图片),离线评测 hit@k/MRR,自动回归。