给私库数据装上「向量」—— 从 ES 搜索到 AI 问答

给私库数据装上「向量」—— 从 ES 搜索到 AI 问答,我的项目是这么干的

系列博客之一:前面几篇讲了 ES 客户端配置和 ES 数据初始化,相当于 "攻略私库" 已经有了。这篇填上最后一块拼图:

向量

。有了它,AI 才能 "读懂" 私库、挑出最相关的 K 条来回答问题。


一、先搞懂一个事:搜索是 "对字",AI 是 "懂意"

之前我们用 ES 做搜索,流程是:

用户输入"北京" → IK 分词拆成词 → 去匹配标题/摘要里含"北京"的攻略

这是字面匹配:一个字都不差,才能命中。它不智能,就是个 "搜索框"。

但现在我们引入 AI 做问答,需求变了。用户会问:

"推荐一个人少、适合发呆的海边"

这句话里根本没有 "海边" 两个字,可我们想召回的是 "小众海岛攻略"。字面匹配做不到,但 AI 能 —— 因为它靠的不是 "字",而是向量

向量是什么?

一段文字 → 交给 Embedding 模型 → 变成

一串数字坐标

(比如 768 个小数)。语义越接近的文字,坐标离得越近。

就像地图上的两个点:意思差不多的两句话,点在向量空间里就挨着。所以 AI 问答的检索,本质是:

把用户的问题也变成一个点,然后去私库里找离它最近的 K 个点(TOPK)

,把这几条数据当 "参考材料" 喂给大模型,让它针对性地回答。

这个 "找最近" 的标准,就是向量。 这就是我前面说的 "标杆 / 条件"。


二、向量从哪来?—— 调 Embedding 接口

向量不是 ES 自己会算的,得让向量化模型算。项目里用的是通义(DashScope)的 Embedding 接口。核心方法长这样(逐行大白话):

public List\<Float> getEmbedding(String text) {

      // ① 请求头:声明我要传 JSON、收 JSON

      HttpHeaders headers = new HttpHeaders();

      headers.setContentType(MediaType.APPLICATION\_JSON);

      headers.setAccept(Stream.of(MediaType.APPLICATION\_JSON).collect(Collectors.toList()));

      // ② 带上 API Key 认证

      headers.setBearerAuth(apiKey);   // 注意:OpenAI 兼容接口用 Bearer,不是 Basic

      // ③ 组装请求体:告诉模型"把这段文字转成 768 维向量"

      EmbeddingReq req = new EmbeddingReq();

      req.setModel(model);                 // 用哪个向量模型

      req.setInput(text);                  // 要向量化的文本

   req.setDimension(dimension.toString()); // 输出维度(768)

   // ④ 请求头 + 请求体 打包成 HttpEntity,发 HTTP 请求

   HttpEntity\<EmbeddingReq> entity = new HttpEntity<>(req, headers);

   EmbeddingResp resp = restTemplate.postForObject(embeddingUrl, entity, EmbeddingResp.class);

   // ⑤ 判空兜底:注意判断顺序,先判 resp 本身,再判里面的 data

   if (resp == null || resp.getData() == null || resp.getData().isEmpty()) {

       throw new RuntimeException("向量计算失败");

   }

   List\<EmbeddingResp.Data> data = resp.getData();

   if (data.get(0) == null || data.get(0).getEmbedding() == null) {

       throw new RuntimeException("向量计算失败");

   }

   // ⑥ 返回第一个结果里的向量(一串 Float 列表)

   return data.get(0).getEmbedding();

}

对应配置:

embedding:

model: text-embedding-v3        # DashScope 官方向量模型名(按你实际开通的填)

dimension: 768                  # 输出向量维度(v3 支持 1024/768/512,768 合法)

url: https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings  # OpenAI 兼容接口

api-key: sk-xxxx                # 换成你自己的,千万别提交到 git

三、建库时,把向量一起存进 ES

普通字段(标题 / 摘要)用 text 存,给 IK 分词用。向量是另一类字段,得用专门类型 dense_vector(密集向量)。

CreateIndexRequest createIndexRequest = CreateIndexRequest.of(r -> r.index(INDEX\_NAME)

          // 一个分片 + 一个副本(注意:原来是 numberOfShards 写了两遍,第二个应为副本数)

          .settings(s -> s.numberOfShards("1").numberOfReplicas("1"))

          .mappings(m -> m

                  // 普通文本字段:分词 + 全文检索

                  .properties("id", p -> p.long\_(l -> l))

                  .properties("title", p -> p.text(t -> t.analyzer("ik\_max\_word")))

                  .properties("subTitle", p -> p.text(t -> t.analyzer("ik\_max\_word")))

                  .properties("summary", p -> p.text(t -> t.analyzer("ik\_max\_word")))

                  // 向量字段:dense\_vector

                  // dims = 维度;index=true = 开启 kNN 近邻搜索;similarity=Cosine 用余弦相似度衡量"多近"

                  .properties("embedding", p -> p.denseVector(d -> d

                          .dims(dimension)

                          .index(true)

                          .similarity(DenseVectorSimilarity.Cosine)))

          ));

client.indices().create(createIndexRequest);

这三个参数要记住,面试常问:

  • dims:向量维度,必须和模型输出的维度一致(768 就是 768),不一致会报错。

  • index(true):等于告诉 ES"我要对向量做 kNN 近邻搜索",ES 会给向量建索引(HNSW),不然只能暴力比对。

  • similarity(Cosine):用余弦相似度衡量两个向量像不像 —— 只管 "方向" 不管 "长短",非常适合文本语义相似度。

初始化:全量把 MySQL 数据向量化后写入

// 先删旧索引再重建(保证幂等,重复执行不报错)

BooleanResponse exists = client.indices().exists(e -> e.index(INDEX\_NAME));

if (exists.value()) {

      client.indices().delete(d -> d.index(INDEX\_NAME));

}

client.indices().create(createIndexRequest);

// 从 MySQL 全量拉取攻略

List\<Strategy> list = remoteStrategyService.list("inner").getData();

for (Strategy strategy : list) {

      StrategyEs es = new StrategyEs();

      BeanUtils.copyProperties(strategy, es);

      // 关键:把"标题 + 副标题 + 摘要"拼成一段话,整段向量化

      StringJoiner sj = new StringJoiner(" ");

      sj.add(es.getTitle());

      sj.add(es.getSubTitle());

      sj.add(es.getSummary());

      List\<Float> embedding = embeddingService.getEmbedding(sj.toString());

      es.setEmbedding(embedding);          // 向量塞进对象

      client.index(i -> i.index(INDEX\_NAME)

              .id(es.getId().toString())   // 用业务主键做文档 id

              .document(es));              // 连同向量一起写进 ES

}

为什么要把 title + subTitle + summary 拼起来再向量化?

因为向量代表的是 "整条攻略的意思"。单独给标题算向量,就丢了正文摘要的信息。拼成一段话,模型才能把握整条攻略在讲什么,检索时匹配得更准。

到这一步,去 ES 里查数据,就能看到每条攻略多了一串密密麻麻的数字 ——

这就是它的向量坐标

。私库数据 + 向量,齐了。


四、AI 问答:先召回,再生成

现在到了核心接口 /chat。整个问答是 "两步走":

@RequestMapping("/chat")

public Object getAnswer(@RequestBody ChatRequest request) throws IOException {

      // ① 用户的问题 → 向量化(用同一个 Embedding 模型)

      List\<Float> questionEmbedding = embeddingService.getEmbedding(request.getQuestion());

      // ② 在 ES 的 embedding 字段上做 kNN:找离问题向量最近的 topK 条攻略

      SearchResponse\<StrategyEs> resp = client.search(sh -> sh

              .index("strategy")

              .knn(k -> k

                      .field("embedding")               // 在哪个向量字段上找

                      .queryVector(questionEmbedding)   // 拿"问题的向量"去找

                      .k(request.getTopK())             // 召回最接近的 K 条

              ), StrategyEs.class);

      // ③ 把命中的攻略源码提取成列表

      List\<StrategyEs> strategies = new ArrayList<>();

      for (Hit\<StrategyEs> hit : resp.hits().hits()) {

          strategies.add(hit.source());

      }

      // ④ 把这 K 条攻略 + 用户问题一起交给大模型,生成回答

      String answer = askAI(request.getQuestion(), strategies);

      // ⑤ 返回:AI 的回答 + 它引用了哪几条攻略(溯源)

      return new ChatResponse(answer, strategies);

}

kNN 是啥? K-Nearest Neighbors,K 近邻 —— 在向量空间里,找离 "问题的点" 最近的 K 个 "攻略的点"。ES 里配了 index(true) 的向量字段,就是给这个查询准备的,速度快。

为什么要返回 strategies? 这就是引用溯源:用户能看到 AI 是基于哪几条攻略回答的,点进去能核对原文。既降低 "大模型瞎编" 的风险,也让回答更可信。


五、askAI:怎么把 "私库数据" 喂给大模型

大模型默认是不认识我们私库的。askAI 干的事就是:把召回的攻略塞进 prompt,让大模型 "看着资料回答"

private String askAI(String question, List\<StrategyEs> strategies) {

      // ① 把召回的 K 条攻略 JSON 化,拼成一段"私库数据"

      StringJoiner sj = new StringJoiner("\n\n");

      for (StrategyEs s : strategies) {

          sj.add(JSON.toJSONString(s));

      }

      String strategiesStr = sj.toString();

      // ② 构造三层消息(OpenAI Chat 兼容格式)

      List\<OpenAIMessage> messages = new ArrayList<>();

      // 第一层 system:把私库数据喂给模型 —— 这就是 RAG 的"检索增强"

      messages.add(new OpenAIMessage("system", strategiesStr));

      // 第二层 system:角色设定 + 回答要求(约束它别乱编)

      messages.add(new OpenAIMessage("system",

          "你是一名资深导游,请结合用户问题和私库数据,并根据互联网信息给出旅游建议。"

          \+ "规划出路线图、避坑指南及规划条件。回答小于200字"));

      // 第三层 user:用户原始问题

      messages.add(new OpenAIMessage("user", question));

      // ③ 组装请求体(OpenAI ChatCompletions 通用格式)

      OpenAIChatRequest body = new OpenAIChatRequest();

      body.setMessages(messages);

      body.setModel(model);                // 对话模型名,按实际填

      body.setTemperature(temperature);    // 随机性:1 表示比较放飞

      body.setTop\_p(top\_p);                // 采样范围:1 表示全范围

      Map\<String, Object> thinking = new HashMap<>();

      thinking.put("type", "enabled");     // 开 thinking 推理

      body.setThinking(thinking);

      // ④ 请求头:Bearer 认证

      HttpHeaders headers = new HttpHeaders();

      headers.setContentType(MediaType.APPLICATION\_JSON);

      headers.setBearerAuth(apiKey);

      HttpEntity\<OpenAIChatRequest> entity = new HttpEntity<>(body, headers);

      String url = baseUrl + "/chat/completions";

      OpenAIChatResponse response = restTemplate.postForObject(url, entity, OpenAIChatResponse.class);

      // ⑤ 判空兜底:逐层判,别一上来就 getChoices().get(0),会 NPE

      if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {

          return "AI没有返回合适的结果";

      }

      OpenAIChatResponse.Choice choice = response.getChoices().get(0);

      if (choice == null || choice.getMessage() == null || choice.getMessage().getContent() == null) {

          return "AI没有返回合适的结果";

      }

      return choice.getMessage().getContent();

}

为什么要 "三层消息"?这是 RAG 的灵魂:

role 内容 作用
第一层 system 召回的 K 条攻略 JSON 喂私库数据—— 模型回答的依据
第二层 system 角色设定 + 回答要求 约束行为—— 结合私库回答、别乱编、控制字数
第三层 user 用户原问题 用户真正想问的

大模型拿到的是 "用户问题 + 和问题最相关的私库资料",回答自然有据可依、能溯源。这就是检索增强生成(RAG):检索(kNN 召回)+ 生成(LLM 作答)。

请求体走的是

OpenAI ChatCompletions 通用格式

/chat/completions

),所以任何兼容 OpenAI 的大模型(DeepSeek、通义等)都能接,换模型只要改 baseUrl 和 model 名。


六、串起来:完整数据流

写入侧(建库)

MySQL 攻略数据

    → 拼 title+subTitle+summary

    → Embedding 接口向量化(768 维)

    → 连同原文一起写入 ES(text 字段 + dense\_vector 字段)

问答侧(/chat)

用户问题

    → Embedding 接口向量化

    → ES kNN 检索,召回最相似的 topK 条攻略

    → 攻略 JSON 作为 system 消息 + 角色设定 + 用户问题 → DeepSeek

    → 返回 AI 回答 + 引用溯源(strategies)

一句话总结:"先找最相关的 K 条私库数据,再让大模型看着它们回答问题。"


七、避坑清单(都是我踩过的)

  1. 模型名写实际开通的:DashScope 向量模型官方名是 text-embedding-v3(支持 1024/768/512 维);对话模型 DeepSeek 官方是 deepseek-chat / deepseek-reasoner。别写奇奇怪怪的版本号,控制台开通什么写什么。

  2. 维度必须一致:mapping 里 dims=768,模型输出也必须是 768,对不上 ES 直接报错。

  3. 判空先判父再判子resp == null → resp.getData() == null → data.get(0) == null,顺序反了就是 NPE。


posted @ 2026-09-05 14:32  霜华序  阅读(5)  评论(0)    收藏  举报