feat(vector): add vector search for Qdrant, Milvus, Weaviate and ChromaDB (#2408)

This commit is contained in:
lexmin0412
2026-07-02 13:58:03 +08:00
committed by GitHub
parent 4fdfbf61c5
commit 3888be8e5e
9 changed files with 142 additions and 18 deletions
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch, defineAsyncComponent } from "vue";
import { Play, RefreshCcw, RotateCcw, Save, Trash2 } from "@lucide/vue";
import { Play, RefreshCcw, RotateCcw, Save, Search, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
@@ -12,7 +12,7 @@ import type { DatabaseType, QueryResult } from "@/types/database";
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
const { t } = useI18n();
type VectorOperationMode = "browse" | "upsert" | "delete";
type VectorOperationMode = "browse" | "upsert" | "delete" | "search";
const props = defineProps<{
connectionId: string;
@@ -32,6 +32,8 @@ const statusMessage = ref("");
const result = ref<QueryResult>(emptyResult());
const operationMode = ref<VectorOperationMode>("browse");
const requestText = ref(defaultRequestText(props.databaseType, props.database, props.collection, operationMode.value));
const searchVector = ref("");
const searchTopK = ref(10);
let loadingTimer: ReturnType<typeof setInterval> | undefined;
const dim = computed(() => props.dimension || 4);
@@ -51,8 +53,28 @@ const productLabel = computed(() => {
}
});
const collectionLabel = computed(() => props.collectionLabel || props.collection || t("vector.collectionFallback"));
const executeLabel = computed(() => (operationMode.value === "browse" ? t("vector.run") : t("vector.apply")));
const operationIcon = computed(() => (operationMode.value === "delete" ? Trash2 : operationMode.value === "upsert" ? Save : Play));
const executeLabel = computed(() => {
switch (operationMode.value) {
case "browse":
return t("vector.run");
case "search":
return t("vector.search");
default:
return t("vector.apply");
}
});
const operationIcon = computed(() => {
switch (operationMode.value) {
case "delete":
return Trash2;
case "upsert":
return Save;
case "search":
return Search;
default:
return Play;
}
});
watch(
() => [props.databaseType, props.database, props.collection] as const,
@@ -64,6 +86,15 @@ watch(
},
);
watch(
() => [operationMode.value, searchVector.value, searchTopK.value] as const,
() => {
if (operationMode.value === "search") {
requestText.value = defaultRequestText(props.databaseType, props.database, props.collection, operationMode.value);
}
},
);
function emptyResult(): QueryResult {
return {
columns: [],
@@ -101,14 +132,22 @@ function defaultRequestText(databaseType: DatabaseType | undefined, database: st
},
],
}
: {
dbName: database || "default",
collectionName: collection,
filter: "",
limit: 100,
outputFields: ["*"],
};
const endpoint = mode === "delete" ? "delete" : mode === "upsert" ? "upsert" : "query";
: mode === "search"
? {
dbName: database || "default",
collectionName: collection,
data: [tryParseVector(searchVector.value)],
limit: searchTopK.value,
outputFields: ["*"],
}
: {
dbName: database || "default",
collectionName: collection,
filter: "",
limit: 100,
outputFields: ["*"],
};
const endpoint = mode === "delete" ? "delete" : mode === "upsert" ? "upsert" : mode === "search" ? "search" : "query";
return `POST /v2/vectordb/entities/${endpoint}\n${JSON.stringify(body, null, 2)}`;
}
if (databaseType === "weaviate") {
@@ -129,6 +168,11 @@ function defaultRequestText(databaseType: DatabaseType | undefined, database: st
2,
)}`;
}
if (mode === "search") {
const vec = tryParseVector(searchVector.value);
const gql = `{ Get { ${collectionName}(limit: ${searchTopK.value}, nearVector: {vector: [${vec.join(",")}]}) { _additional { distance id } } } }`;
return `POST /v1/graphql\n${JSON.stringify({ query: gql }, null, 2)}`;
}
return `GET /v1/objects?class=${encodeURIComponent(collectionName)}&limit=100`;
}
if (databaseType === "chromadb") {
@@ -139,6 +183,9 @@ function defaultRequestText(databaseType: DatabaseType | undefined, database: st
if (mode === "upsert") {
return `POST /api/v2/tenants/default_tenant/databases/default_database/collections/${encodeURIComponent(collectionId)}/upsert\n${JSON.stringify({ ids: ["id1"], embeddings: [sampleVector()], documents: ["sample document"], metadatas: [{}] }, null, 2)}`;
}
if (mode === "search") {
return `POST /api/v2/tenants/default_tenant/databases/default_database/collections/${encodeURIComponent(collectionId)}/query\n${JSON.stringify({ query_embeddings: [tryParseVector(searchVector.value)], n_results: searchTopK.value, include: ["documents", "metadatas", "distances"] }, null, 2)}`;
}
return `POST /api/v2/tenants/default_tenant/databases/default_database/collections/${encodeURIComponent(collectionId)}/get\n${JSON.stringify({ limit: 100, include: ["documents", "metadatas"] }, null, 2)}`;
}
const collectionPath = pathSegment(collection);
@@ -163,9 +210,20 @@ function defaultRequestText(databaseType: DatabaseType | undefined, database: st
2,
)}`;
}
if (mode === "search") {
return `POST /collections/${collectionPath}/points/search\n${JSON.stringify({ vector: tryParseVector(searchVector.value), limit: searchTopK.value, with_payload: true, with_vector: false }, null, 2)}`;
}
return `POST /collections/${collectionPath}/points/scroll\n${JSON.stringify({ limit: 100, with_payload: true, with_vector: false }, null, 2)}`;
}
function tryParseVector(input: string): number[] {
try {
const parsed = JSON.parse(input);
if (Array.isArray(parsed)) return parsed;
} catch {}
return sampleVector();
}
function startTimer() {
stopTimer();
const startedAt = Date.now();
@@ -224,7 +282,7 @@ async function runRequest() {
try {
const nextResult = await executeRequestText(requestText.value);
if (executionId.value !== id) return;
if (operationMode.value === "browse") {
if (operationMode.value === "browse" || operationMode.value === "search") {
result.value = nextResult;
} else {
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse");
@@ -273,6 +331,7 @@ function setOperationMode(mode: VectorOperationMode) {
<div class="flex shrink-0 items-center gap-1.5">
<div class="mr-1 flex h-7 overflow-hidden rounded-md border bg-muted/30 p-0.5">
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'browse' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('browse')">{{ t("vector.browse") }}</button>
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'search' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('search')">{{ t("vector.search") }}</button>
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'upsert' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('upsert')">{{ t("vector.upsert") }}</button>
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'delete' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('delete')">{{ t("vector.delete") }}</button>
</div>
@@ -291,6 +350,16 @@ function setOperationMode(mode: VectorOperationMode) {
</div>
</div>
<div v-if="operationMode === 'search'" class="flex shrink-0 flex-wrap items-center gap-3 border-b px-3 py-2">
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{{ t("vector.vectorLabel") }}</span>
<input v-model="searchVector" class="h-7 w-80 rounded border bg-muted/30 px-2 font-mono text-xs outline-none focus:border-primary" placeholder="[0.1, 0.2, ...]" spellcheck="false" />
</div>
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>topK</span>
<input v-model.number="searchTopK" type="number" min="1" max="1000" class="h-7 w-16 rounded border bg-muted/30 px-2 text-xs outline-none focus:border-primary" />
</div>
</div>
<div class="grid min-h-0 flex-1 grid-rows-[minmax(9rem,15rem)_1fr]">
<div class="min-h-0 border-b">
<textarea v-model="requestText" class="dbx-editor-font-family h-full w-full resize-none bg-background px-3 py-2 text-xs leading-5 outline-none" :aria-label="t('vector.requestEditor')" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" />
+2
View File
@@ -1886,6 +1886,7 @@ export default {
collectionFallback: "collection",
productCollection: "{product} collection",
browse: "Browse",
search: "Search",
upsert: "Upsert",
delete: "Delete",
reset: "Reset",
@@ -1894,6 +1895,7 @@ export default {
apply: "Apply",
requestEditor: "Vector request editor",
operationSuccess: "Operation completed. The collection preview has been refreshed.",
vectorLabel: "Vector",
},
history: {
title: "History",
+2
View File
@@ -1867,6 +1867,7 @@ export default withEnglishFallback({
collectionFallback: "colección",
productCollection: "Colección de {product}",
browse: "Examinar",
search: "Buscar",
upsert: "Upsert",
delete: "Eliminar",
reset: "Restablecer",
@@ -1875,6 +1876,7 @@ export default withEnglishFallback({
apply: "Aplicar",
requestEditor: "Editor de solicitudes vectoriales",
operationSuccess: "Operación completada. La vista previa de la colección se ha actualizado.",
vectorLabel: "Vector",
},
history: {
title: "Historial",
+2
View File
@@ -1887,6 +1887,7 @@ export default withEnglishFallback({
collectionFallback: "collezione",
productCollection: "Collezione {product}",
browse: "Sfoglia",
search: "Cerca",
upsert: "Upsert",
delete: "Elimina",
reset: "Reimposta",
@@ -1895,6 +1896,7 @@ export default withEnglishFallback({
apply: "Applica",
requestEditor: "Editor richieste vettoriali",
operationSuccess: "Operazione completata. L'anteprima della collezione e stata aggiornata.",
vectorLabel: "Vettore",
},
history: {
title: "Cronologia",
+2
View File
@@ -1868,6 +1868,7 @@ export default withEnglishFallback({
collectionFallback: "コレクション",
productCollection: "{product} コレクション",
browse: "参照",
search: "検索",
upsert: "Upsert",
delete: "削除",
reset: "リセット",
@@ -1876,6 +1877,7 @@ export default withEnglishFallback({
apply: "適用",
requestEditor: "ベクターリクエストエディタ",
operationSuccess: "操作が完了しました。コレクションプレビューが更新されました。",
vectorLabel: "ベクトル",
},
history: {
title: "履歴",
+2
View File
@@ -1879,6 +1879,7 @@ export default withEnglishFallback({
collectionFallback: "coleção",
productCollection: "coleção {product}",
browse: "Navegar",
search: "Pesquisar",
upsert: "Upsert",
delete: "Excluir",
reset: "Redefinir",
@@ -1887,6 +1888,7 @@ export default withEnglishFallback({
apply: "Aplicar",
requestEditor: "Editor de requisição de vetor",
operationSuccess: "Operação concluída. A pré-visualização da coleção foi atualizada.",
vectorLabel: "Vetor",
},
history: {
title: "Histórico",
+2
View File
@@ -1889,6 +1889,7 @@ export default withEnglishFallback({
collectionFallback: "集合",
productCollection: "{product} 集合",
browse: "浏览",
search: "搜索",
upsert: "写入",
delete: "删除",
reset: "重置",
@@ -1897,6 +1898,7 @@ export default withEnglishFallback({
apply: "应用",
requestEditor: "向量请求编辑器",
operationSuccess: "操作已完成,集合预览已刷新。",
vectorLabel: "向量",
},
history: {
title: "历史",
+2
View File
@@ -1771,6 +1771,7 @@ export default withEnglishFallback({
collectionFallback: "集合",
productCollection: "{product} 集合",
browse: "瀏覽",
search: "搜尋",
upsert: "寫入",
delete: "刪除",
reset: "重設",
@@ -1779,6 +1780,7 @@ export default withEnglishFallback({
apply: "套用",
requestEditor: "向量請求編輯器",
operationSuccess: "操作已完成,集合預覽已重新整理。",
vectorLabel: "向量",
},
history: {
title: "歷史",
+46 -5
View File
@@ -338,28 +338,65 @@ async fn get_chroma_collection_detail(client: &VectorClient, collection: &str) -
}
fn chroma_get_response_to_rows(body: &Value) -> Vec<Value> {
let ids = body.get("ids").and_then(Value::as_array).cloned().unwrap_or_default();
let docs = body.get("documents").and_then(Value::as_array).cloned().unwrap_or_default();
let metas = body.get("metadatas").and_then(Value::as_array).cloned().unwrap_or_default();
let flatten = |key: &str| -> Vec<Value> {
let raw = body.get(key).and_then(Value::as_array).cloned().unwrap_or_default();
let is_nested = raw.first().and_then(|v| v.as_array()).is_some();
if is_nested {
raw.iter().flat_map(|v| v.as_array().cloned().unwrap_or_default()).collect()
} else {
raw
}
};
let ids = flatten("ids");
let documents = flatten("documents");
let metadatas = flatten("metadatas");
let distances = flatten("distances");
ids.into_iter()
.enumerate()
.map(|(i, id_val)| {
let mut row = serde_json::Map::new();
row.insert("id".to_string(), id_val);
if let Some(doc) = docs.get(i) {
if let Some(doc) = documents.get(i) {
row.insert("document".to_string(), doc.clone());
}
if let Some(Value::Object(meta_obj)) = metas.get(i) {
if let Some(Value::Object(meta_obj)) = metadatas.get(i) {
for (k, v) in meta_obj {
row.insert(k.clone(), v.clone());
}
}
if let Some(dist) = distances.get(i) {
row.insert("distance".to_string(), dist.clone());
}
Value::Object(row)
})
.collect()
}
fn weaviate_graphql_to_rows(body: &Value) -> Option<Vec<Value>> {
let get_obj = body.pointer("/data/Get")?.as_object()?;
let (_class_name, items) = get_obj.iter().next()?;
let items = items.as_array()?;
Some(
items
.iter()
.map(|item| {
let mut obj = match item {
Value::Object(m) => m.clone(),
_ => return item.clone(),
};
if let Some(Value::Object(additional)) = obj.remove("_additional") {
for (k, v) in additional {
obj.entry(k).or_insert(v);
}
}
Value::Object(obj)
})
.collect(),
)
}
fn weaviate_collection_names_from_schema(body: &Value) -> Vec<String> {
body.get("classes")
.and_then(Value::as_array)
@@ -569,6 +606,10 @@ fn json_to_query_result(status: u16, body: Value, start: Instant) -> QueryResult
let rows = chroma_get_response_to_rows(&body);
return values_to_query_result(rows, start);
}
// Weaviate GraphQL search response
if let Some(items) = weaviate_graphql_to_rows(&body) {
return values_to_query_result(items, start);
}
QueryResult {
columns: vec!["status".to_string(), "response".to_string()],
column_types: Vec::new(),