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

创建一个关于智能博物馆导览案例,使用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,自动回归。
相关推荐
Goboy8 小时前
OpenClaw 卸载教程,一篇讲透
ai编程
饼干哥哥9 小时前
这43个OpenClaw Skill,直接干翻跨境电商
aigc
饼干哥哥10 小时前
把n8n逼死后,Openclaw重构了跨境电商的内容创作流程
aigc
刀法如飞10 小时前
AI时代,程序员都应该是需求描述工程师
程序员·aigc·ai编程·需求文档
小兵张健10 小时前
白嫖党的至暗时期
人工智能·chatgpt·aigc
还好还好不是吗13 小时前
使用 trae skills免费codeview 你的最新pr代码
ai编程·trae
孟健13 小时前
得物前端部门,没了
ai编程
该用户已不存在14 小时前
除了OpenClaw还有谁?五款安全且高效的开源AI智能体
人工智能·aigc·ai编程
量子位14 小时前
Meta亚历山大王走人?小扎回应了
meta·aigc