向量检索
核心功能
本节完成:
- 向量检索:从数据库中检索出语义相似的向量片段。
- 推理问答:基于搜索片段及用户问题生成自动化回答。
片段搜索功能
数据传输对象 (VO)
ParagraphSearchResultVo
package nexus.io.mosskb.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class ParagraphSearchResultVo {
private Long id;
private String content, title;
private Long dataset_id;
private String dataset_name;
private Long document_id;
private String document_name;
private String document_type;
private String document_url;
private Long paragraph_id;
private Boolean active;
private Integer hit_num;
private String status;
public ParagraphSearchResultVo(long id, String content, String document_name, long document_id) {
this.id = id;
this.content = content;
this.document_name = document_name;
this.document_id = document_id;
}
}
- 封装了片段搜索的结果,包括片段的基本信息及所属文档和数据集的相关信息。
- 使用 Lombok 注解简化代码。
搜索服务实现
MossKbParagraphRetrieveService(当前检索实现)
package nexus.io.mosskb.service.kb;
import java.util.ArrayList;
import java.util.List;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.vo.MossKbRetrieveResult;
import nexus.io.mosskb.vo.ParagraphSearchResultVo;
public class MossKbParagraphRetrieveService {
public MossKbRetrieveResult retrieve(Long[] datasets, Float threshold, Integer limit, String question) {
return retrieve(datasets, threshold, limit, question, "embedding");
}
public MossKbRetrieveResult retrieve(Long[] datasets, Float threshold, Integer limit, String question, String mode) {
long start = System.currentTimeMillis();
List<ParagraphSearchResultVo> paragraphs = new ArrayList<>();
for (Row row : searchRows(datasets, threshold, limit, question, mode)) {
paragraphs.add(row.toBean(ParagraphSearchResultVo.class));
}
return new MossKbRetrieveResult().setStep_type("search_step").setModel_name(KnowledgeModelService.embeddingModel())
.setProblem_text(question).setCost(0).setRun_time((System.currentTimeMillis() - start) / 1000.0)
.setParagraph_list(paragraphs);
}
public List<Row> searchRows(Long[] datasets, Float threshold, Integer limit, String question, String mode) {
if (datasets == null || datasets.length == 0) {
return List.of();
}
if (question == null || question.isBlank()) {
throw new IllegalArgumentException("检索问题不能为空");
}
String searchMode = mode == null ? "embedding" : mode;
if (!List.of("embedding", "keywords", "blend").contains(searchMode)) {
throw new IllegalArgumentException("未知的检索模式");
}
if (!searchMode.equals("keywords")) {
java.util.Map<Long, List<Long>> groups = new java.util.LinkedHashMap<>();
for (Row dataset : Db.find("select id,coalesce(embedding_mode_id,1002) as model_id from moss_kb_dataset where id=any(?) and deleted=0", (Object) datasets)) {
groups.computeIfAbsent(dataset.getLong("model_id"), key -> new ArrayList<>()).add(dataset.getLong("id"));
}
List<Row> all = new ArrayList<>();
for (java.util.Map.Entry<Long, List<Long>> group : groups.entrySet()) {
Long vectorId = Aop.get(KbEmbeddingService.class).getVectorIdForModel(question, group.getKey());
all.addAll(searchGroup(group.getValue().toArray(new Long[0]), threshold, limit, question, searchMode, vectorId));
}
all.sort(java.util.Comparator.<Row>comparingDouble(row -> ((Number) row.get("similarity")).doubleValue()).reversed().thenComparing(row -> row.getLong("id")));
return new ArrayList<>(all.subList(0, Math.min(all.size(), limit == null ? 10 : Math.max(1, Math.min(50, limit)))));
}
return searchGroup(datasets, threshold, limit, question, searchMode, null);
}
private List<Row> searchGroup(Long[] datasets, Float threshold, Integer limit, String question, String searchMode, Long vectorId) {
List<Object> args = new ArrayList<>();
String score;
String cacheJoin = "";
if (searchMode.equals("keywords")) {
score = "word_similarity(?,p.content)";
args.add(question);
} else {
score = "1-(p.embedding <=> c.v)";
if (searchMode.equals("blend")) {
score = "(" + score + ")+word_similarity(?,p.content)";
args.add(question);
}
cacheJoin = " join moss_kb_embedding_cache c on c.id=?";
args.add(vectorId);
}
String sql = "select sub.*,similarity as comprehensive_score from (select p.id,p.content,p.title,p.status,p.hit_num,p.is_active,p.dataset_id,p.document_id,d.name as document_name,ds.name as dataset_name," + score
+ " as similarity from moss_kb_paragraph p join moss_kb_document d on d.id=p.document_id join moss_kb_dataset ds on ds.id=p.dataset_id" + cacheJoin
+ " where p.is_active=true and d.is_active=true and p.deleted=0 and d.deleted=0 and ds.deleted=0 and p.dataset_id=any(?)) sub where similarity>=? order by similarity desc,id limit ?";
args.add(datasets);
args.add(threshold == null ? 0 : Math.max(0, threshold));
args.add(limit == null ? 10 : Math.max(1, Math.min(50, limit)));
return Db.find(sql, args.toArray());
}
public MossKbRetrieveResult searchV1(Long[] datasets, Float threshold, Integer limit, String question) {
return retrieve(datasets, threshold, limit, question);
}
public List<ParagraphSearchResultVo> searchV10(Long[] datasets, Float threshold, Integer limit, String question) {
return retrieve(datasets, threshold, limit, question).getParagraph_list();
}
}
- 向量生成:调用
MossKbEmbeddingService生成问题向量。 - SQL 查询:从
SqlTemplates加载查询模板并执行搜索。 - 结果封装:将查询结果转换为
ParagraphSearchResultVo列表,供前端使用。
单元测试
下方保留原检索服务的测试调用示例。其旧类名和固定测试库仅用于理解测试步骤;当前可运行回归测试为 IterativeRetrievalServiceTest 等,不能将历史示例直接加入现有测试目录。
MossKbParagraphSearchServiceTest
package nexus.io.mosskb.service;
import java.util.List;
import org.junit.Test;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.config.DbConfig;
import nexus.io.mosskb.vo.ParagraphSearchResultVo;
import nexus.io.tio.boot.testing.TioBootTest;
import nexus.io.tio.utils.json.JsonUtils;
public class MossKbParagraphSearchServiceTest {
@Test
public void test() {
TioBootTest.runWith(DbConfig.class);
Long[] datasetIdArray = { 446225135519784960L };
Float similarity = 0.2f;
Integer top_n = 3;
String question = "office hour";
List<ParagraphSearchResultVo> result = Aop.get(MossKbParagraphSearchService.class).search(datasetIdArray, similarity, top_n, question);
System.out.println(JsonUtils.toJson(result));
}
}
- 验证搜索功能的正确性。
- 定义测试用例参数并输出搜索结果的 JSON 表示。
搜索逻辑与 SQL 解析
搜索逻辑
- 接收用户查询并向量化。
- 构建 SQL 查询以获取相关片段。
- 根据相似度排序结果并限制返回数量。
- 返回封装后的结果。
SQL 查询模板
下方保留原 SQL 原理示例。当前运行 SQL 以本文上面的 MossKbParagraphRetrieveService 为准,增加模型分组、停用/删除过滤和结果数量限制。
--# kb.search_paragraph_with_dataset_ids
SELECT
sub.id,
sub.content,
sub.title,
sub.status,
sub.hit_num,
sub.is_active,
sub.dataset_id,
sub.document_id,
sub.dataset_name,
sub.document_name,
sub.similarity,
sub.similarity AS comprehensive_score
FROM (
SELECT
d.name AS document_name,
ds.name AS dataset_name,
p.id,
p.content,
p.title,
p.status,
p.hit_num,
p.is_active,
p.dataset_id,
p.document_id,
(1 - (p.embedding <=> c.v)) AS similarity
FROM
moss_kb_paragraph p
JOIN
moss_kb_document d ON p.document_id = d.id
JOIN
moss_kb_dataset ds ON p.dataset_id = ds.id
JOIN
moss_kb_embedding_cache c ON c.id = ?
WHERE
p.is_active = TRUE
AND p.deleted = 0
AND ds.deleted = 0
AND p.dataset_id = ANY (?)
) sub
WHERE
sub.similarity > ?
ORDER BY
sub.similarity DESC
LIMIT ?;
- 子查询:通过关联片段、文档、数据集及向量缓存表计算相似度。
- 过滤:只返回激活且未删除的片段。
- 排序与限制:按相似度降序排列,并限制返回数量。
结果解析示例
[
{
"id": "446228832295108608",
"content": "I reserve the right to be offline after 6 pm and on Saturdays...",
"dataset_name": "ICS 111",
"document_name": "ICS111_31391_Miller_Syllabus_F24.pdf",
"similarity": 0.85
}
]
- 字段:
id:片段 ID。content:片段内容。similarity:片段与问题的相似度。dataset_name和document_name:片段所属数据集和文档。
推理功能实现
数据交互流程
- 获取用户问题。
- 搜索相关片段。
- 构造提示词并请求大语言模型。
- 返回问答结果。
示例提示词模板
用户问题:{question}
相关片段:
1. {content1}
2. {content2}
...
请根据以上信息生成回答:
大语言模型处理
调用模型接口,传入用户问题和片段信息,生成回答。下一节将获详细介绍
总结
通过实现片段搜索与推理问答,系统能够提供高效且智能的问答服务。未来可扩展功能包括:
- 增强多语言支持。
- 提高嵌入模型的准确性。
- 实现更复杂的上下文推理能力。
当前模式、阈值与多轮检索
MossKbParagraphRetrieveService 按指定知识库返回可用分段,过滤已删除、停用的文档和片段。
embedding:使用1 - cosine_distance作为相似度。多个知识库按各自的模型生成查询向量,再合并候选。keywords:使用pg_trgm的word_similarity,不调用向量接口。blend:向量相似度与关键词得分相加。
接口支持阈值和结果数。结果数限制在 1 至 50,默认 10;相同得分用片段 ID 确定顺序。混合得分不能直接套用纯向量模式的阈值,不同模型的分数分布也应通过样本校准。
问答场景在此单次检索能力上执行证据核验和多次补充检索,停止条件与结果聚合见多轮检索。命中测试用于观察检索片段,不自动等同于完整答案质量评测。
命中测试接口示例
GET /api/dataset/{datasetId}/hit_test?query_text=行政复议期限&similarity=0&top_number=10&search_mode=embedding
Authorization: Bearer <JWT_TOKEN>
应用范围命中测试使用 /api/application/{applicationId}/hit_test,参数相同。search_mode 可为 embedding、keywords 或 blend。命中测试观察候选片段,聊天再执行改写、证据核验及缺口检索循环。
MossKbRetrieveResult result = Aop.get(MossKbParagraphRetrieveService.class)
.retrieve(new Long[] {datasetId}, 0.0F, 10, "行政复议期限", "embedding");
上述调用需要已初始化数据库和对应模型凭据。生成最终回答使用 UniChatClient,历史问答仅用于理解上下文,不能代替本轮检索证据。
