Skip to content

向量检索 ​

核心功能 ​

本节完成:

  • 向量检索:从数据库中检索出语义相似的向量片段。
  • 推理问答:基于搜索片段及用户问题生成自动化回答。

片段搜索功能 ​

数据传输对象 (VO) ​

ParagraphSearchResultVo ​

java
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(当前检索实现) ​

java
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 ​

java
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 解析 ​

搜索逻辑 ​

  1. 接收用户查询并向量化。
  2. 构建 SQL 查询以获取相关片段。
  3. 根据相似度排序结果并限制返回数量。
  4. 返回封装后的结果。

SQL 查询模板 ​

下方保留原 SQL 原理示例。当前运行 SQL 以本文上面的 MossKbParagraphRetrieveService 为准,增加模型分组、停用/删除过滤和结果数量限制。

sql
--# 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 ?;
  • 子查询:通过关联片段、文档、数据集及向量缓存表计算相似度。
  • 过滤:只返回激活且未删除的片段。
  • 排序与限制:按相似度降序排列,并限制返回数量。

结果解析示例 ​

json
[
  {
    "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:片段所属数据集和文档。

推理功能实现 ​

数据交互流程 ​

  1. 获取用户问题。
  2. 搜索相关片段。
  3. 构造提示词并请求大语言模型。
  4. 返回问答结果。

示例提示词模板 ​

text
用户问题:{question}
相关片段:
1. {content1}
2. {content2}
...
请根据以上信息生成回答:

大语言模型处理 ​

调用模型接口,传入用户问题和片段信息,生成回答。下一节将获详细介绍


总结 ​

通过实现片段搜索与推理问答,系统能够提供高效且智能的问答服务。未来可扩展功能包括:

  • 增强多语言支持。
  • 提高嵌入模型的准确性。
  • 实现更复杂的上下文推理能力。

当前模式、阈值与多轮检索 ​

MossKbParagraphRetrieveService 按指定知识库返回可用分段,过滤已删除、停用的文档和片段。

  • embedding:使用 1 - cosine_distance 作为相似度。多个知识库按各自的模型生成查询向量,再合并候选。
  • keywords:使用 pg_trgm 的 word_similarity,不调用向量接口。
  • blend:向量相似度与关键词得分相加。

接口支持阈值和结果数。结果数限制在 1 至 50,默认 10;相同得分用片段 ID 确定顺序。混合得分不能直接套用纯向量模式的阈值,不同模型的分数分布也应通过样本校准。

问答场景在此单次检索能力上执行证据核验和多次补充检索,停止条件与结果聚合见多轮检索。命中测试用于观察检索片段,不自动等同于完整答案质量评测。

命中测试接口示例 ​

http
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。命中测试观察候选片段,聊天再执行改写、证据核验及缺口检索循环。

java
MossKbRetrieveResult result = Aop.get(MossKbParagraphRetrieveService.class)
    .retrieve(new Long[] {datasetId}, 0.0F, 10, "行政复议期限", "embedding");

上述调用需要已初始化数据库和对应模型凭据。生成最终回答使用 UniChatClient,历史问答仅用于理解上下文,不能代替本轮检索证据。

MIT Licensed