检索性能优化
本篇先给出与当前代码的核对结论,再逐项说明已经落地的优化及其实现,最后给出尚未执行的索引与分区设计。文中的分区方案是可选数据库设计示例,不是当前初始化脚本已执行的迁移;已有业务表不得直接按示例替换。当前数据库结构以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 地址与模型名称的摘要+维度+内容摘要共同构成:
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方法里,此处为便于阅读做了收敛。远程调用刻意保留在锁内,同一文本的并发请求因此只会真正发出一次。
性能相关的三个要点:
- 缓存命中即省一次远程请求。文档导入与检索问题共用同一份缓存,重复提问、重复导入同一份文档都不会再次计费。
Striped<Lock>把并发压成一次请求。相同文本并发到达时,只有一个线程真正调用远程接口,其余线程阻塞后读缓存,避免“惊群式”重复调用。它只覆盖单个 JVM,多实例部署时数据库层仍可能出现重复写入。(md5, m)复合索引让“内容摘要 + 模型标识”的等值查询直接走索引;md5单列索引服务只按内容摘要探测的场景。参数化写法让 PostgreSQL 可以复用执行计划。
2.2 按向量模型分组,一个问题只向量化一次
一个应用可以关联多个知识库,而不同知识库可能配置了不同的向量模型。把不同模型空间的向量放在一起比大小是没有意义的,因此检索前先按 coalesce(embedding_mode_id, 1002) 分组,每组只对问题做一次向量化,再各自检索、最后合并重排:
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 中的私有方法:
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~2 | max(0, threshold),缺省 0 | 负阈值不会放大候选集 |
limit / top_n / top_number | 命中测试页 1~10000 | min(50, max(1, limit)),缺省 10 | 单次检索最多返回 50 条 |
blend 评分上限 | 2 | 向量相似度(≤1)+ 文本相似度(≤1) | 阈值上限为 2 |
注意:命中测试接口接受
top_number=100这类较大的值,但服务层会把候选条数截到 50。这是当前实现的确定行为,不是前端失配。
2.4 三种检索模式的成本对照
| 模式 | 评分表达式 | 是否调用向量接口 | 阈值缺省 | 适用场景 |
|---|---|---|---|---|
embedding | 1-(p.embedding <=> c.v) | 是(每模型 1 次,可命中缓存) | 0.6(前端默认) | 语义相近、用词不同的提问 |
keywords | word_similarity(?, p.content) | 否 | 0 | 专有名词、编号、条文号等字面匹配 |
blend | 向量相似度 + word_similarity | 是 | 0.6 | 既有语义又有硬性关键词的提问 |
keywords 模式的价值在于零向量调用:不访问向量模型,也不依赖 moss_kb_embedding_cache 的向量列,只有一条数据库查询。在向量服务不稳定或额度受限时,它是可用的降级通道。三者共用同一套过滤条件(is_active、deleted、dataset_id = any(?)),因此切换模式不会改变可见数据范围。
三、写入侧:批量与并发
检索性能也取决于写入侧的吞吐与重复计算。导入分段时,先为每个分段取向量(命中缓存则零成本),再在一个事务里批量写入:
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 收结果:
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_rounds | 3 | 最多几轮检索与核验 |
kb.agent.evidence_tokens | 16000 | 累计证据的估算 token 预算 |
| 证据段落条数 | 40 | 累计去重后的段落上限 |
循环在以下任一条件成立时停止,并把停止原因写回检索轨迹:sufficient(资料充分)、max_rounds(达到轮次上限)、duplicate_query(查询重复)、no_new_evidence(未发现新增资料)、evidence_budget(达到资料容量上限)、no_followup_query(没有后续查询)、no_datasets(未关联知识库)、assessment_error(核验未完成)。同一问题重复检索会被 normalize 后的查询集合拦住,不会因为模型反复生成同一句话而反复打数据库:
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)。控制器只做参数读取与归属校验,检索逻辑复用同一服务:
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);
}
前端把三个参数合并后提交,并按综合得分排序展示:
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。
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;
}
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 事件推送检索进度,回答结束后可展开检索轨迹,看到每轮查询、新增片段数与停止原因:
<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 单独安装):
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 尚未建立的索引
以下索引当前数据库中没有创建,属于可选的后续优化;建立前需要评估写入放大与索引体积,并确认向量维度与模型一致:
-- 尚未执行:候选集过滤与关联字段
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);
注意事项:
- 向量索引改变的是召回方式,不只是速度。建立 HNSW / IVFFlat 后向量检索变为近似检索,需要同时调
hnsw.ef_search或ivfflat.probes在延迟与召回率之间取舍;对“必须精确”的场景应保留精确扫描或提高搜索宽度。 - 索引与向量维度、模型绑定。更换向量模型或维度后必须重建该索引,否则检索会直接报维度错误;换模型时还要清空
moss_kb_embedding_cache并重新向量化文档。 - 三元组索引不会自动加速现有 SQL。当前
keywords/blend把word_similarity(...)放在选择列里做评分,这类表达式通常不会被 GIN 索引加速。若要真正用上索引,需要额外用%>之类的操作符做前置过滤,再对少量候选算分。 search_vector目前是空字段。moss_kb_paragraph与moss_kb_sentence都预留了TSVECTOR列,但检索链路中没有任何地方读取它,也没有写入逻辑(只有遗留代码在旧表名上写入空值)。若要启用真正的全文检索,需要补上to_tsvector的生成列或写入逻辑,并为它建立 GIN 索引;在此之前不要把它当作已生效的检索通道。- 索引不是分区的替代品。单表在没有向量索引时,检索耗时随候选行数线性增长;分区只能减少单个分区内的扫描量,无法把精确计算变成近似计算。
七、按 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 个分区:
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 个分区:
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 进行列表分区的表:
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,可以为每个值创建一个分区:
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,可以创建一个默认分区:
CREATE TABLE moss_kb_paragraph_default PARTITION OF moss_kb_paragraph
DEFAULT;
7.3 选择合适的分区策略
- 哈希分区:适用于
dataset_id分布均匀且不需要按特定值进行查询优化的情况。分区数量需要根据实际数据量和查询性能需求进行调整。 - 列表分区:适用于
dataset_id值较少且已知的情况,可以按特定值进行优化,但当dataset_id数量较多或动态变化时,维护成本较高。
7.4 注意事项
- 索引和约束:在分区表上创建索引和约束时,需要在每个分区上单独创建,或者使用分区约束排除。分区表上的向量索引同样按分区建立。
- 查询优化:确保查询中包含
dataset_id以利用分区裁剪(partition pruning)提升性能。 - 维护分区:根据数据增长情况,定期添加新的分区以适应新的
dataset_id值。 - 主键约束:分区键必须包含在主键或唯一约束中,因此
dataset_id需要进入主键定义,这会同时影响依赖id唯一性的写入逻辑。 - 写入路径:当前检索 SQL 使用
p.dataset_id = any(?),属于可裁剪的写法;但按id直接查片段、按document_id删除分段的路径不会因为分区而变快,仍需要 6.2 节的普通索引。
通过上述步骤,可以根据 dataset_id 对 moss_kb_paragraph 表进行有效的分区,从而提升查询性能和数据管理的灵活性。
7.5 查询
使用列表分区时,查询是针对主表还是针对子表进行的?
查询目标:主表
查询主表:在 PostgreSQL 中,无论使用何种分区策略(列表分区、哈希分区、范围分区等),所有的查询都是针对**主表(父表)**发起的。主表本身不存储数据,而是作为分区的入口,负责将查询路由到相应的子分区表。
自动分区裁剪(Partition Pruning):当查询中包含分区键(在您的例子中是
dataset_id)时,PostgreSQL 会自动识别并只访问相关的子分区表,从而提高查询性能。例如:SELECT * FROM moss_kb_paragraph WHERE dataset_id = 2;上述查询会自动路由到
moss_kb_paragraph_dataset2分区,而不需要扫描其他分区。查询不包含分区键:如果查询中不包含分区键,PostgreSQL 可能需要扫描所有相关的子分区,这可能会影响性能。因此,尽量在查询中包含分区键以利用分区裁剪的优势。
总结:无论是插入、更新还是查询操作,都只需要针对主表执行操作,PostgreSQL 会根据分区策略自动处理具体的子表。
