创建一个关于智能博物馆导览案例

创建一个关于智能博物馆导览案例,使用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 集合设计(多向量 + 稀疏向量)

  • collectionsexhibits
  • 向量槽位 (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 并在 filternamed 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,自动回归。
相关推荐
Goboy19 分钟前
跳一跳游戏:Trae 轻松实现平台跳跃挑战
ai编程·trae
Goboy22 分钟前
飞行棋游戏:Trae 轻松实现骰子与棋盘对战
ai编程·trae
向上的车轮1 小时前
Spring Boot生态中ORM对数据治理的支持有哪些?
spring boot·数据治理·orm
武昌库里写JAVA2 小时前
使用 Java 开发 Android 应用:Kotlin 与 Java 的混合编程
java·vue.js·spring boot·sql·学习
CoderJia程序员甲3 小时前
GitHub 热榜项目 - 日榜(2025-08-21)
ai·开源·github·ai编程
量子位3 小时前
DeepSeek一句话让国产芯片集体暴涨!背后的UE8M0 FP8到底是个啥
ai编程·deepseek
量子位3 小时前
稚晖君新大招:机器人二次开发0门槛了!
llm·ai编程
java水泥工4 小时前
Java项目:基于SpringBoot和VUE的在线拍卖系统(源码+数据库+文档)
java·vue.js·spring boot
章鱼大王4 小时前
让机器“听懂人话”:多轮对话里的意图识别实战
ai编程