给私库数据装上「向量」—— 从 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 条私库数据,再让大模型看着它们回答问题。"
七、避坑清单(都是我踩过的)
-
模型名写实际开通的:DashScope 向量模型官方名是
text-embedding-v3(支持 1024/768/512 维);对话模型 DeepSeek 官方是deepseek-chat/deepseek-reasoner。别写奇奇怪怪的版本号,控制台开通什么写什么。 -
维度必须一致:mapping 里
dims=768,模型输出也必须是 768,对不上 ES 直接报错。 -
判空先判父再判子:
resp == null → resp.getData() == null → data.get(0) == null,顺序反了就是 NPE。

浙公网安备 33010602011771号