Skip to content

检索性能优化 ​

本篇先给出与当前代码的核对结论,再逐项说明已经落地的优化及其实现,最后给出尚未执行的索引与分区设计。文中的分区方案是可选数据库设计示例,不是当前初始化脚本已执行的迁移;已有业务表不得直接按示例替换。当前数据库结构以01所述脚本为准。

一、现状核对 ​

检索链路上真正影响耗时与费用的环节有四个:问题向量化(远程调用)、候选片段筛选(数据库)、检索轮次(调用次数)、结果集大小(送入模型的 token)。当前实现针对前三项做了优化,第四项由参数控制;数据库侧的近似索引与分区尚未建立。

优化项当前状态说明
向量结果缓存 moss_kb_embedding_cache已实现相同文本不重复请求向量模型
缓存并发去重 Striped<Lock>已实现同一进程内相同文本只发一次远程请求
缓存键按模型实例隔离已实现不同模型/接口地址不会互相命中
按知识库向量模型分组检索已实现一个应用挂多个知识库时按模型分组,每组只向量化一次问题
命中向量复用缓存行已实现JOIN moss_kb_embedding_cache c ON c.id = ?,避免把问题向量当作参数重复传输与解析
关键词检索(pg_trgm)已实现keywords 模式不调用向量接口,成本为零
混合检索 blend已实现向量相似度与文本相似度相加,阈值上限为 2
候选集裁剪已实现阈值归一为非负,条数限制在 1~50
导入时批量写库已实现Db.batchSave(..., 2000) 分批提交
导入时并发向量化已实现CompletionService + 固定线程池并发取向量
迭代检索的证据预算已实现轮次、累计 token、段落条数三重上限,见多轮检索
moss_kb_embedding_cache 的 btree 索引已实现md5 与 (md5, m) 两个索引
moss_kb_paragraph 的 dataset_id/document_id 索引未实现当前无任何索引,过滤条件走顺序扫描
向量近似最近邻索引(HNSW / IVFFlat)未实现每次检索对候选行做精确距离计算
tsvector 全文检索的 GIN 索引未实现search_vector 字段已建,但检索链路未使用
按 dataset_id 的分区表未实现见第七节,仅为可选设计方案

结论:“检索性能优化”这个主题在当前代码中已部分实现(缓存、分组、模式、裁剪、预算、批量与并发写入),而分区表没有实现,分区相关内容在本篇中降级为可选方案保留。

二、减少向量模型的调用次数 ​

2.1 先查缓存,再按模型隔离缓存键 ​

向量化是整个检索链路中最贵的一步。KbEmbeddingService 先按文本摘要查询缓存表,未命中才调用远程 embeddings 接口,并把结果写回缓存。缓存键由模型实例(或默认模型标识)+ API 地址与模型名称的摘要+维度+内容摘要共同构成:

java
package nexus.io.mosskb.service.kb;

import java.util.Arrays;
import java.util.concurrent.locks.Lock;
import org.postgresql.util.PGobject;
import com.google.common.util.concurrent.Striped;
import nexus.io.chat.PlatformInput;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.db.utils.PgVectorUtils;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

/** Cache keys isolate model instances; documents and queries use the same model. */
public class KbEmbeddingService {
  private static final Striped<Lock> LOCKS = Striped.lock(256);

  public Long getVectorId(String text) { return cached(text).getLong("id"); }
  public PGobject getVector(String text) { return cached(text).get("v"); }
  public PGobject getVectorForModel(String text, Long modelId) { return cached(text, modelId).get("v"); }
  public Long getVectorIdForModel(String text, Long modelId) { return cached(text, modelId).getLong("id"); }

  private Row cached(String text) { return cached(text, 1002L); }

  private Row cached(String text, Long modelId) {
    if (text == null || text.isBlank()) {
      throw new IllegalArgumentException("Embedding text must not be empty");
    }
    String hash = Md5Utils.md5Hex(text);
    long resolvedId = modelId == null ? 1002L : modelId;
    Row selected = Db.findById("moss_kb_model", resolvedId);
    if (selected == null || !"EMBEDDING".equals(selected.getStr("model_type"))) {
      throw new IllegalArgumentException("知识库配置的向量模型不存在");
    }
    nexus.io.mosskb.vo.CredentialVo credential = ModelCatalogService.credential(selected);
    String base = ModelCatalogService.baseUrl(credential.getApi_base());
    String model = resolvedId == 1002L ? KnowledgeModelService.embeddingModel() + ":1024"
        : resolvedId + ":" + Md5Utils.md5Hex(base + "|" + selected.getStr("model_name")) + ":1024";
    Lock lock = LOCKS.get(model + hash);
    lock.lock();
    try {
      Row row = Db.findFirst("select id,v from moss_kb_embedding_cache where md5=? and m=?", hash, model);
      if (row != null) {
        return row;
      }
      // 未命中:在锁内调用远程 embeddings 接口,校验 1024 维与有限数值后写回缓存
      return embedAndCache(text, hash, model, base, credential, selected);
    } finally {
      lock.unlock();
    }
  }
}

片段中的 embedAndCache(...) 是占位名:实际实现把远程 HTTP 调用、维度与数值校验、缓存写入内联在 cached 方法里,此处为便于阅读做了收敛。远程调用刻意保留在锁内,同一文本的并发请求因此只会真正发出一次。

性能相关的三个要点:

  1. 缓存命中即省一次远程请求。文档导入与检索问题共用同一份缓存,重复提问、重复导入同一份文档都不会再次计费。
  2. Striped<Lock> 把并发压成一次请求。相同文本并发到达时,只有一个线程真正调用远程接口,其余线程阻塞后读缓存,避免“惊群式”重复调用。它只覆盖单个 JVM,多实例部署时数据库层仍可能出现重复写入。
  3. (md5, m) 复合索引让“内容摘要 + 模型标识”的等值查询直接走索引;md5 单列索引服务只按内容摘要探测的场景。参数化写法让 PostgreSQL 可以复用执行计划。

2.2 按向量模型分组,一个问题只向量化一次 ​

一个应用可以关联多个知识库,而不同知识库可能配置了不同的向量模型。把不同模型空间的向量放在一起比大小是没有意义的,因此检索前先按 coalesce(embedding_mode_id, 1002) 分组,每组只对问题做一次向量化,再各自检索、最后合并重排:

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 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("未知的检索模式");
    }
    // keywords 模式不需要向量,直接单次查询
    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);
  }
}

分组带来的性能收益:问题向量化的远程调用次数等于不同向量模型的个数,而不是知识库个数;同一模型下的多个知识库合并成一次 dataset_id = any(?) 查询,把 N 次往返压成 1 次。代价是每个分组各自使用 limit,合并后需要再裁剪一次,因此各组返回的条数与最终条数不一定相等。

2.3 结果集裁剪:阈值归一与条数上限 ​

真正决定送入模型 token 量的是最终候选条数。limit 在前端可以填到很大的值,但后端统一收敛到 1~50。下面是同一个 MossKbParagraphRetrieveService 中的私有方法:

java
  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());
  }
参数前端取值范围后端归一化效果
similarity(阈值)0~1;blend 模式 0~2max(0, threshold),缺省 0负阈值不会放大候选集
limit / top_n / top_number命中测试页 1~10000min(50, max(1, limit)),缺省 10单次检索最多返回 50 条
blend 评分上限2向量相似度(≤1)+ 文本相似度(≤1)阈值上限为 2

注意:命中测试接口接受 top_number=100 这类较大的值,但服务层会把候选条数截到 50。这是当前实现的确定行为,不是前端失配。

2.4 三种检索模式的成本对照 ​

模式评分表达式是否调用向量接口阈值缺省适用场景
embedding1-(p.embedding <=> c.v)是(每模型 1 次,可命中缓存)0.6(前端默认)语义相近、用词不同的提问
keywordsword_similarity(?, p.content)否0专有名词、编号、条文号等字面匹配
blend向量相似度 + word_similarity是0.6既有语义又有硬性关键词的提问

keywords 模式的价值在于零向量调用:不访问向量模型,也不依赖 moss_kb_embedding_cache 的向量列,只有一条数据库查询。在向量服务不稳定或额度受限时,它是可用的降级通道。三者共用同一套过滤条件(is_active、deleted、dataset_id = any(?)),因此切换模式不会改变可见数据范围。

三、写入侧:批量与并发 ​

检索性能也取决于写入侧的吞吐与重复计算。导入分段时,先为每个分段取向量(命中缓存则零成本),再在一个事务里批量写入:

java
package nexus.io.mosskb.service.kb;

import java.util.ArrayList;
import java.util.List;
import com.jfinal.kit.Kv;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.vo.Paragraph;
import nexus.io.mosskb.vo.ParagraphBatchVo;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.hutool.FilenameUtils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

public class MossKbParagraphSplitService {

  public ResultVo batch(Long userId, Long datasetId, List<ParagraphBatchVo> documents) {
    // 权限与参数校验(略)
    List<Kv> result = new ArrayList<>();
    for (ParagraphBatchVo input : documents) {
      Long existingId = Db.queryLong("select id from moss_kb_document where user_id=? and file_id=? and dataset_id=?", userId, input.getId(), datasetId);
      long documentId = existingId == null ? SnowflakeIdUtils.id() : existingId;
      List<Row> paragraphs = new ArrayList<>();
      int charLength = 0;
      String type = FilenameUtils.getSuffix(input.getName());
      // 远程请求全部完成后再发布文档或替换已有分段
      for (Paragraph p : input.getParagraphs()) {
        charLength += p.getContent().length();
        paragraphs.add(Row.by("id", SnowflakeIdUtils.id()).set("source_id", input.getId())
            .set("source_type", type).set("title", p.getTitle()).set("content", p.getContent())
            .set("md5", Md5Utils.md5Hex(p.getContent())).set("status", "2").set("hit_num", 0)
            .set("is_active", true).set("dataset_id", datasetId).set("document_id", documentId)
            .set("embedding", Aop.get(KbEmbeddingService.class).getVectorForModel(p.getContent(), dataset.getLong("embedding_mode_id"))));
      }
      boolean saved = Db.tx(() -> {
        if (existingId == null) {
          Db.save("moss_kb_document", document);
        } else {
          Db.update("moss_kb_document", document);
          Db.update("delete from moss_kb_paragraph where document_id=?", documentId);
        }
        Db.batchSave("moss_kb_paragraph", paragraphs, 2000);
        return true;
      });
      if (!saved) {
        return ResultVo.fail("文档保存失败,请重试");
      }
      result.add(document.toKv());
    }
    return ResultVo.ok(result);
  }
}

要点:向量计算放在事务之外,事务内只做删除与批量插入,避免远程调用长时间占用数据库连接与行锁;batchSave 每 2000 条提交一次,避免单条往返。重新导入同一文档时按 document_id 覆盖,不会累积重复分段。

句子级摘要则把向量化改成并发执行,用 CompletionService 收结果:

java
package nexus.io.mosskb.service.kb;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletionService;
import java.util.concurrent.ExecutorCompletionService;
import java.util.concurrent.Future;
import org.postgresql.util.PGobject;
import lombok.extern.slf4j.Slf4j;
import nexus.io.db.activerecord.Db;
import nexus.io.db.activerecord.Row;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.constant.MossKbTableNames;
import nexus.io.mosskb.model.MossKbSentence;
import nexus.io.mosskb.utils.ExecutorServiceUtils;
import nexus.io.tio.utils.crypto.Md5Utils;
import nexus.io.tio.utils.snowflake.SnowflakeIdUtils;

@Slf4j
public class MossKbSentenceService {
  KbEmbeddingService mossKbEmbeddingService = Aop.get(KbEmbeddingService.class);
  CompletionService<Row> completionServiceRow = new ExecutorCompletionService<>(ExecutorServiceUtils.getExecutorService());

  public boolean splitToSentenceAndSave(Long dataset_id, String modelName, List<Row> paragraphRecords, Long documentIdFinal) {
    List<MossKbSentence> sentences = new ArrayList<>();
    // 递归分句并构造 MossKbSentence(略)
    List<Future<Row>> futures = new ArrayList<>(sentences.size());
    for (MossKbSentence sentence : sentences) {
      futures.add(completionServiceRow.submit(() -> {
        PGobject vector = mossKbEmbeddingService.getVector(sentence.getContent(), modelName);
        Row record = sentence.toRow();
        record.set("embedding", vector);
        return record;
      }));
    }
    List<Row> sentenceRows = new ArrayList<>();
    for (int i = 0; i < sentences.size(); i++) {
      try {
        Row record = completionServiceRow.take().get();
        if (record != null) {
          sentenceRows.add(record);
        }
      } catch (Exception e) {
        log.error(e.getMessage(), e);
      }
    }
    return Db.tx(() -> {
      Db.deleteById(MossKbTableNames.moss_kb_sentence, "document_id", documentIdFinal);
      Db.batchSave(MossKbTableNames.moss_kb_sentence, sentenceRows, 2000);
      return true;
    });
  }
}

并发取向量显著压缩导入耗时,但并发度受线程池大小约束;单个分段失败只记录日志、不中断整批,最终写入的是成功的那部分。同一份文本的并发请求会先被 2.1 节的分段锁合并,因此并发并不会成倍放大远程调用量。

四、检索轮次:迭代检索的预算 ​

会话问答不是单次检索,而是“检索 → 核验 → 针对缺口再检索”的有界循环,详见多轮检索。从性能角度看,它由三个上限共同约束:

上限默认值作用
kb.agent.max_rounds3最多几轮检索与核验
kb.agent.evidence_tokens16000累计证据的估算 token 预算
证据段落条数40累计去重后的段落上限

循环在以下任一条件成立时停止,并把停止原因写回检索轨迹:sufficient(资料充分)、max_rounds(达到轮次上限)、duplicate_query(查询重复)、no_new_evidence(未发现新增资料)、evidence_budget(达到资料容量上限)、no_followup_query(没有后续查询)、no_datasets(未关联知识库)、assessment_error(核验未完成)。同一问题重复检索会被 normalize 后的查询集合拦住,不会因为模型反复生成同一句话而反复打数据库:

java
package nexus.io.mosskb.service.kb;

import java.util.Locale;

  private String normalize(String query) {
    return query.toLowerCase(Locale.ROOT).replaceAll("[\\s\\p{P}]+", "");
  }

迭代检索用额外轮次换取召回率,检索的次数上限是“轮次 × 每轮查询数(最多 2)”。如果对响应时间敏感,把 kb.agent.max_rounds 调小是最直接的降本手段;核验调用失败会记为 assessment_error 而不是“资料充分”,不会把失败当成结论。

五、可观测与调参入口 ​

5.1 命中测试 ​

命中测试页面用于在不触发大模型的情况下评估召回效果,参数与检索链路完全一致:

方法与路径说明
GET /api/dataset/{id}/hit_test按知识库检索候选片段
GET /api/application/{applicationId}/hit_test按应用关联的全部知识库检索

查询参数:query_text、similarity、top_number、search_mode(embedding / keywords / blend)。控制器只做参数读取与归属校验,检索逻辑复用同一服务:

java
package nexus.io.mosskb.controller;

import nexus.io.annotation.Get;
import nexus.io.annotation.RequestPath;
import nexus.io.jfinal.aop.Aop;
import nexus.io.mosskb.service.kb.MossKbDatasetHitTestService;
import nexus.io.model.result.ResultVo;
import nexus.io.tio.boot.http.TioRequestContext;
import nexus.io.tio.http.common.HttpRequest;

@RequestPath("/api/dataset")
public class ApiDatasetController {

  @Get("/{id}/hit_test")
  public ResultVo hitTest(Long id, HttpRequest request) {
    String query_text = request.getParam("query_text");
    Double similarity = request.getDouble("similarity");
    Integer top_number = request.getInt("top_number");
    String search_mode = request.getParam("search_mode");
    Long userId = TioRequestContext.getUserIdLong();
    return Aop.get(MossKbDatasetHitTestService.class).hitTest(userId, id, query_text, similarity, top_number,
        search_mode);
  }

前端把三个参数合并后提交,并按综合得分排序展示:

ts
import datasetApi from '@/api/dataset'
import applicationApi from '@/api/application'
import { arraySort } from '@/utils/utils'

const formInline = ref({
  similarity: 0.0,
  top_number: 10,
  search_mode: 'embedding'
})

function changeHandle(val: string) {
  if (val === 'keywords') {
    cloneForm.value.similarity = 0
  } else {
    cloneForm.value.similarity = 0.6
  }
}

function getHitTestList() {
  const obj = {
    query_text: inputValue.value,
    ...formInline.value
  }
  if (isDataset.value) {
    datasetApi.getDatasetHitTest(id, obj, loading).then((res) => {
      paragraphDetail.value = res.data && arraySort(res.data, 'comprehensive_score', true)
    })
  } else if (isApplication.value) {
    applicationApi.getApplicationHitTest(id, obj, loading).then((res) => {
      paragraphDetail.value = res.data && arraySort(res.data, 'comprehensive_score', true)
    })
  }
}

界面上切换检索模式时会同步把阈值重置到该模式的合理起点(关键词 0,其余 0.6),避免把 0.6 的向量阈值带进关键词模式后一直查不到结果。

5.2 应用参数 ​

应用级参数保存在应用的 dataset_setting 中:search_mode、similarity、top_n、max_paragraph_char_number。新建应用与参数弹窗的默认值一致(embedding / 0.6 / 3)。会话问答读取这些参数;当 dataset_setting 缺失时,后端回退为 embedding 模式、阈值 0.0、条数 10。

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 MossKbDatasetSettingVo {
  private Integer top_n;
  private Float similarity;
  private Integer max_paragraph_char_number;
  private String search_mode;
  private MossKbApplicationNoReferencesSetting no_references_setting;
}
java
      MossKbDatasetSettingVo dataset_setting = applicationVo.getDataset_setting();
      Float similarity = 0.0f;
      Integer top_n = 10;
      if (dataset_setting != null) {
        if (dataset_setting.getSimilarity() != null) {
          similarity = dataset_setting.getSimilarity();
        }
        if (dataset_setting.getTop_n() != null) {
          top_n = dataset_setting.getTop_n();
        }
      }

5.3 检索过程的展示 ​

流式回答期间通过 agent_status 事件推送检索进度,回答结束后可展开检索轨迹,看到每轮查询、新增片段数与停止原因:

vue
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ status?: any; trace?: any[]; context?: any; stop?: string; done?: boolean }>()
const labels: Record<string, string> = {
  sufficient: '资料充分', max_rounds: '达到检索轮次上限', no_new_evidence: '未发现新增资料',
  duplicate_query: '查询重复', evidence_budget: '达到资料容量上限', no_followup_query: '无后续查询',
  no_datasets: '未关联知识库', assessment_error: '资料核验未完成'
}
const stopLabel = computed(() => labels[props.stop || ''] || '已完成')
</script>

这些字段不参与回答生成,只用于诊断“慢在哪一步”:是向量服务慢(retrieving 阶段耗时长)、数据库慢(同样阶段但缓存已命中),还是轮次多(trace.length 大)。

六、索引现状与缺口 ​

6.1 已建立的索引 ​

初始化脚本中与检索相关的扩展与索引如下(vector 与建表语句在 init-db.sql,pg_trgm 由 004-keyword-search.sql 单独安装):

sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE TABLE IF NOT EXISTS "public"."moss_kb_embedding_cache" (
  "id" BIGINT PRIMARY KEY,
  "t" TEXT,
  "m" VARCHAR,
  "v" VECTOR,
  "md5" VARCHAR
);

CREATE INDEX IF NOT EXISTS "idx_moss_kb_embedding_cache_md5" ON "public"."moss_kb_embedding_cache" USING btree ("md5");
CREATE INDEX IF NOT EXISTS "idx_moss_kb_embedding_cache_md5_m" ON "public"."moss_kb_embedding_cache" USING btree ("md5", "m");

moss_kb_embedding_cache 是读多写少、按内容摘要等值命中的表,btree 索引足够;它的 v 列只按主键被 JOIN 取出,不需要向量索引。

6.2 尚未建立的索引 ​

以下索引当前数据库中没有创建,属于可选的后续优化;建立前需要评估写入放大与索引体积,并确认向量维度与模型一致:

sql
-- 尚未执行:候选集过滤与关联字段
CREATE INDEX IF NOT EXISTS idx_moss_kb_paragraph_dataset_id  ON moss_kb_paragraph(dataset_id) WHERE deleted = 0;
CREATE INDEX IF NOT EXISTS idx_moss_kb_paragraph_document_id ON moss_kb_paragraph(document_id) WHERE deleted = 0;

-- 尚未执行:向量近似最近邻索引(余弦距离,与 SQL 中的 <=> 对应)
CREATE INDEX IF NOT EXISTS idx_moss_kb_paragraph_embedding ON moss_kb_paragraph
  USING hnsw (embedding vector_cosine_ops);

-- 尚未执行:文本相似度的三元组索引
CREATE INDEX IF NOT EXISTS idx_moss_kb_paragraph_content_trgm ON moss_kb_paragraph
  USING gin (content gin_trgm_ops);

注意事项:

  1. 向量索引改变的是召回方式,不只是速度。建立 HNSW / IVFFlat 后向量检索变为近似检索,需要同时调 hnsw.ef_search 或 ivfflat.probes 在延迟与召回率之间取舍;对“必须精确”的场景应保留精确扫描或提高搜索宽度。
  2. 索引与向量维度、模型绑定。更换向量模型或维度后必须重建该索引,否则检索会直接报维度错误;换模型时还要清空 moss_kb_embedding_cache 并重新向量化文档。
  3. 三元组索引不会自动加速现有 SQL。当前 keywords / blend 把 word_similarity(...) 放在选择列里做评分,这类表达式通常不会被 GIN 索引加速。若要真正用上索引,需要额外用 %> 之类的操作符做前置过滤,再对少量候选算分。
  4. search_vector 目前是空字段。moss_kb_paragraph 与 moss_kb_sentence 都预留了 TSVECTOR 列,但检索链路中没有任何地方读取它,也没有写入逻辑(只有遗留代码在旧表名上写入空值)。若要启用真正的全文检索,需要补上 to_tsvector 的生成列或写入逻辑,并为它建立 GIN 索引;在此之前不要把它当作已生效的检索通道。
  5. 索引不是分区的替代品。单表在没有向量索引时,检索耗时随候选行数线性增长;分区只能减少单个分区内的扫描量,无法把精确计算变成近似计算。

七、按 dataset_id 对 moss_kb_paragraph 分区(可选设计) ​

本节为可选数据库设计示例,尚未执行。当前初始化脚本没有创建任何分区表,实际库中的 moss_kb_paragraph 仍是普通表,且没有索引。迁移已有业务表需要停机、数据搬迁与索引重建,不能直接执行下面的 DROP TABLE / CREATE TABLE。

要根据 dataset_id 对 moss_kb_paragraph 表进行分区,可以使用 PostgreSQL 的分区表功能。根据 dataset_id 的数据分布情况,可以选择哈希分区、列表分区或范围分区。以下是使用哈希分区和列表分区的两种实现方式。

7.1 使用哈希分区 ​

哈希分区适用于 dataset_id 分布较为均匀且没有特定分组需求的情况。

步骤 1:创建分区表 ​

创建一个分区表,并指定按 dataset_id 进行哈希分区。例如,使用 4 个分区:

sql
DROP TABLE IF EXISTS moss_kb_paragraph;

CREATE TABLE moss_kb_paragraph (
  id BIGINT PRIMARY KEY,
  source_id BIGINT NOT NULL,
  source_type VARCHAR NOT NULL,
  content VARCHAR NOT NULL,
  title VARCHAR NOT NULL,
  status VARCHAR NOT NULL,
  hit_num INT NOT NULL,
  is_active BOOLEAN NOT NULL,
  embedding VECTOR NOT NULL, -- 使用 pgvector 类型
  meta JSONB NOT NULL,
  dataset_id BIGINT NOT NULL,
  document_id BIGINT NOT NULL,
  paragraph_id BIGINT,
  search_vector TSVECTOR,
  creator VARCHAR(64) DEFAULT '',
  create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '',
  update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  deleted SMALLINT DEFAULT 0,
  tenant_id BIGINT NOT NULL DEFAULT 0
) PARTITION BY HASH (dataset_id);

说明:上面沿用了当前单表的主键写法。创建分区表时唯一索引必须包含分区键,因此 id BIGINT PRIMARY KEY 需要改成包含 dataset_id 的复合键,或改用其他唯一性保证方式,详见 7.4 第 4 条。下面的列表分区示例同理。

步骤 2:创建分区 ​

接下来,创建具体的分区表。例如,创建 4 个分区:

sql
CREATE TABLE moss_kb_paragraph_p0 PARTITION OF moss_kb_paragraph
  FOR VALUES WITH (MODULUS 4, REMAINDER 0);

CREATE TABLE moss_kb_paragraph_p1 PARTITION OF moss_kb_paragraph
  FOR VALUES WITH (MODULUS 4, REMAINDER 1);

CREATE TABLE moss_kb_paragraph_p2 PARTITION OF moss_kb_paragraph
  FOR VALUES WITH (MODULUS 4, REMAINDER 2);

CREATE TABLE moss_kb_paragraph_p3 PARTITION OF moss_kb_paragraph
  FOR VALUES WITH (MODULUS 4, REMAINDER 3);

7.2 使用列表分区 ​

如果 dataset_id 有特定的分组需求或数量较少,可以使用列表分区。

步骤 1:创建分区表 ​

创建一个按 dataset_id 进行列表分区的表:

sql
DROP TABLE IF EXISTS moss_kb_paragraph;

CREATE TABLE moss_kb_paragraph (
  id BIGINT PRIMARY KEY,
  source_id BIGINT NOT NULL,
  source_type VARCHAR NOT NULL,
  content VARCHAR NOT NULL,
  title VARCHAR NOT NULL,
  status VARCHAR NOT NULL,
  hit_num INT NOT NULL,
  is_active BOOLEAN NOT NULL,
  embedding VECTOR NOT NULL, -- 使用 pgvector 类型
  meta JSONB NOT NULL,
  dataset_id BIGINT NOT NULL,
  document_id BIGINT NOT NULL,
  paragraph_id BIGINT,
  search_vector TSVECTOR,
  creator VARCHAR(64) DEFAULT '',
  create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updater VARCHAR(64) DEFAULT '',
  update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  deleted SMALLINT DEFAULT 0,
  tenant_id BIGINT NOT NULL DEFAULT 0
) PARTITION BY LIST (dataset_id);

步骤 2:创建分区 ​

假设有特定的 dataset_id 值,例如 1, 2, 3, 4,可以为每个值创建一个分区:

sql
CREATE TABLE moss_kb_paragraph_dataset1 PARTITION OF moss_kb_paragraph
  FOR VALUES IN (1);

CREATE TABLE moss_kb_paragraph_dataset2 PARTITION OF moss_kb_paragraph
  FOR VALUES IN (2);

CREATE TABLE moss_kb_paragraph_dataset3 PARTITION OF moss_kb_paragraph
  FOR VALUES IN (3);

CREATE TABLE moss_kb_paragraph_dataset4 PARTITION OF moss_kb_paragraph
  FOR VALUES IN (4);

对于未列出的 dataset_id,可以创建一个默认分区:

sql
CREATE TABLE moss_kb_paragraph_default PARTITION OF moss_kb_paragraph
  DEFAULT;

7.3 选择合适的分区策略 ​

  • 哈希分区:适用于 dataset_id 分布均匀且不需要按特定值进行查询优化的情况。分区数量需要根据实际数据量和查询性能需求进行调整。
  • 列表分区:适用于 dataset_id 值较少且已知的情况,可以按特定值进行优化,但当 dataset_id 数量较多或动态变化时,维护成本较高。

7.4 注意事项 ​

  1. 索引和约束:在分区表上创建索引和约束时,需要在每个分区上单独创建,或者使用分区约束排除。分区表上的向量索引同样按分区建立。
  2. 查询优化:确保查询中包含 dataset_id 以利用分区裁剪(partition pruning)提升性能。
  3. 维护分区:根据数据增长情况,定期添加新的分区以适应新的 dataset_id 值。
  4. 主键约束:分区键必须包含在主键或唯一约束中,因此 dataset_id 需要进入主键定义,这会同时影响依赖 id 唯一性的写入逻辑。
  5. 写入路径:当前检索 SQL 使用 p.dataset_id = any(?),属于可裁剪的写法;但按 id 直接查片段、按 document_id 删除分段的路径不会因为分区而变快,仍需要 6.2 节的普通索引。

通过上述步骤,可以根据 dataset_id 对 moss_kb_paragraph 表进行有效的分区,从而提升查询性能和数据管理的灵活性。

7.5 查询 ​

使用列表分区时,查询是针对主表还是针对子表进行的? ​

查询目标:主表

  • 查询主表:在 PostgreSQL 中,无论使用何种分区策略(列表分区、哈希分区、范围分区等),所有的查询都是针对**主表(父表)**发起的。主表本身不存储数据,而是作为分区的入口,负责将查询路由到相应的子分区表。

  • 自动分区裁剪(Partition Pruning):当查询中包含分区键(在您的例子中是 dataset_id)时,PostgreSQL 会自动识别并只访问相关的子分区表,从而提高查询性能。例如:

    sql
    SELECT * FROM moss_kb_paragraph WHERE dataset_id = 2;

    上述查询会自动路由到 moss_kb_paragraph_dataset2 分区,而不需要扫描其他分区。

  • 查询不包含分区键:如果查询中不包含分区键,PostgreSQL 可能需要扫描所有相关的子分区,这可能会影响性能。因此,尽量在查询中包含分区键以利用分区裁剪的优势。

总结:无论是插入、更新还是查询操作,都只需要针对主表执行操作,PostgreSQL 会根据分区策略自动处理具体的子表。

MIT Licensed