feat: add t-SNE visualization for FAISS index and enhance knowledge base retrieval with debug mode

This commit is contained in:
Soulter
2025-10-24 21:22:46 +08:00
parent 4e9cce76da
commit 4cedc6d3c8
6 changed files with 254 additions and 128 deletions
+1
View File
@@ -248,6 +248,7 @@ class KnowledgeBaseManager:
"chunk_index": r.metadata.get("chunk_index", 0),
"content": r.content,
"score": r.score,
"char_count": r.metadata.get("char_count", 0),
}
for r in results
]
+23 -7
View File
@@ -9,6 +9,7 @@ from quart import request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from .route import Route, Response, RouteContext
from ..utils import generate_tsne_visualization
class KnowledgeBaseRoute(Route):
@@ -294,7 +295,6 @@ class KnowledgeBaseRoute(Route):
- top_k_dense: 密集检索数量 (可选)
- top_k_sparse: 稀疏检索数量 (可选)
- top_m_final: 最终返回数量 (可选)
- enable_rerank: 是否启用Rerank (可选)
"""
try:
kb_manager = self._get_kb_manager()
@@ -811,7 +811,7 @@ class KnowledgeBaseRoute(Route):
- query: 查询文本 (必填)
- kb_ids: 知识库 ID 列表 (必填)
- top_k: 返回结果数量 (可选, 默认 5)
- enable_rerank: 是否启用Rerank (可选, 默认使用知识库配置)
- debug: 是否启用调试模式,返回 t-SNE 可视化图片 (可选, 默认 False)
"""
try:
kb_manager = self._get_kb_manager()
@@ -819,6 +819,7 @@ class KnowledgeBaseRoute(Route):
query = data.get("query")
kb_names = data.get("kb_names")
debug = data.get("debug", False)
if not query:
return Response().error("缺少参数 query").__dict__
@@ -836,11 +837,26 @@ class KnowledgeBaseRoute(Route):
if results:
result_list = results["results"]
return (
Response()
.ok({"results": result_list, "total": len(result_list), "query": query})
.__dict__
)
response_data = {
"results": result_list,
"total": len(result_list),
"query": query,
}
# Debug 模式:生成 t-SNE 可视化
if debug:
try:
img_base64 = await generate_tsne_visualization(
query, kb_names, kb_manager
)
if img_base64:
response_data["visualization"] = img_base64
except Exception as e:
logger.error(f"生成 t-SNE 可视化失败: {e}")
logger.error(traceback.format_exc())
response_data["visualization_error"] = str(e)
return Response().ok(response_data).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
+161
View File
@@ -0,0 +1,161 @@
import base64
import os
import traceback
from io import BytesIO
from astrbot.api import logger
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
async def generate_tsne_visualization(
query: str, kb_names: list[str], kb_manager: KnowledgeBaseManager
) -> str | None:
"""生成 t-SNE 可视化图片
Args:
query: 查询文本
kb_names: 知识库名称列表
kb_manager: 知识库管理器
Returns:
图片路径或 None
"""
try:
import faiss
import numpy as np
import matplotlib
matplotlib.use("Agg") # 使用非交互式后端
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
except ImportError as e:
raise Exception(
"缺少必要的库以生成 t-SNE 可视化。请安装 matplotlib 和 scikit-learn: {e}"
) from e
try:
# 获取第一个知识库的向量数据
kb_helper: KBHelper | None = None
for kb_name in kb_names:
kb_helper = await kb_manager.get_kb_by_name(kb_name)
if kb_helper:
break
if not kb_helper:
logger.warning("未找到知识库")
return None
kb = kb_helper.kb
index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss"
# 读取 FAISS 索引
if not os.path.exists(index_path):
logger.warning(f"FAISS 索引不存在: {index_path}")
return None
index = faiss.read_index(index_path)
if index.ntotal == 0:
logger.warning("索引为空")
return None
# 提取所有向量
logger.info(f"提取 {index.ntotal} 个向量用于可视化...")
if isinstance(index, faiss.IndexIDMap):
base_index = faiss.downcast_index(index.index)
if hasattr(base_index, "reconstruct_n"):
vectors = base_index.reconstruct_n(0, index.ntotal)
else:
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
for i in range(index.ntotal):
base_index.reconstruct(i, vectors[i])
elif hasattr(index, "reconstruct_n"):
vectors = index.reconstruct_n(0, index.ntotal)
else:
vectors = np.zeros((index.ntotal, index.d), dtype=np.float32)
for i in range(index.ntotal):
index.reconstruct(i, vectors[i])
# 获取查询向量
vec_db: FaissVecDB = kb_helper.vec_db # type: ignore
embedding_provider = vec_db.embedding_provider
query_embedding = await embedding_provider.get_embedding(query)
query_vector = np.array([query_embedding], dtype=np.float32)
# 合并所有向量和查询向量
all_vectors = np.vstack([vectors, query_vector])
# t-SNE 降维
logger.info("开始 t-SNE 降维...")
perplexity = min(30, all_vectors.shape[0] - 1)
tsne = TSNE(n_components=2, random_state=42, perplexity=perplexity)
vectors_2d = tsne.fit_transform(all_vectors)
# 分离知识库向量和查询向量
kb_vectors_2d = vectors_2d[:-1]
query_vector_2d = vectors_2d[-1]
# 可视化
logger.info("生成可视化图表...")
plt.figure(figsize=(14, 10))
# 绘制知识库向量
scatter = plt.scatter(
kb_vectors_2d[:, 0],
kb_vectors_2d[:, 1],
alpha=0.5,
s=40,
c=range(len(kb_vectors_2d)),
cmap="viridis",
label="Knowledge Base Vectors",
)
# 绘制查询向量(红色 X
plt.scatter(
query_vector_2d[0],
query_vector_2d[1],
c="red",
s=300,
marker="X",
edgecolors="black",
linewidths=2,
label="Query",
zorder=5,
)
# 添加查询文本标注
plt.annotate(
"Query",
(query_vector_2d[0], query_vector_2d[1]),
xytext=(10, 10),
textcoords="offset points",
fontsize=10,
bbox={"boxstyle": "round,pad=0.5", "fc": "yellow", "alpha": 0.7},
arrowprops={"arrowstyle": "->", "connectionstyle": "arc3,rad=0"},
)
plt.colorbar(scatter, label="Vector Index")
plt.title(
f"t-SNE Visualization: Query in Knowledge Base\n"
f"({index.ntotal} vectors, {index.d} dimensions, KB: {kb.kb_name})",
fontsize=14,
pad=20,
)
plt.xlabel("t-SNE Dimension 1", fontsize=12)
plt.ylabel("t-SNE Dimension 2", fontsize=12)
plt.grid(True, alpha=0.3)
plt.legend(fontsize=10, loc="upper right")
# base64 编码图片返回
buffer = BytesIO()
plt.savefig(buffer, format="png", dpi=150, bbox_inches="tight")
plt.close()
buffer.seek(0)
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
return img_base64
except Exception as e:
logger.error(f"生成 t-SNE 可视化时出错: {e}")
logger.error(traceback.format_exc())
return None
@@ -245,7 +245,6 @@ onMounted(() => {
<style scoped>
.kb-detail-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
@@ -340,10 +339,6 @@ onMounted(() => {
/* 响应式设计 */
@media (max-width: 768px) {
.kb-detail-page {
padding: 16px;
}
.kb-title {
flex-direction: column;
align-items: flex-start;
+1 -56
View File
@@ -152,6 +152,7 @@
:label="t('create.embeddingModelLabel')"
variant="outlined"
class="mb-4"
:disabled="true"
@update:model-value="handleEmbeddingProviderChange"
>
<template #item="{ props, item }">
@@ -166,10 +167,6 @@
</template>
</v-select>
<v-alert type="warning" variant="tonal" density="compact" class="mb-4" v-if="editingKB && showEmbeddingWarning">
<strong>注意:</strong> 修改嵌入模型会导致现有的向量数据失效,建议重新上传文档。不同的嵌入模型生成的向量不兼容,可能导致检索结果不准确。
</v-alert>
<v-select
v-model="formData.rerank_provider_id"
:items="rerankProviders"
@@ -278,38 +275,6 @@
{{ snackbar.text }}
</v-snackbar>
<!-- Embedding Provider 修改确认对话框 -->
<v-dialog v-model="embeddingChangeDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-warning text-white">
<v-icon class="mr-2">mdi-alert</v-icon>
确认修改嵌入模型
</v-card-title>
<v-card-text class="pa-6">
<v-alert type="warning" variant="tonal" class="mb-4">
<strong>警告:</strong> 修改嵌入模型将导致以下影响:
</v-alert>
<ul class="text-body-2">
<li>现有的向量数据将失效</li>
<li>检索功能可能无法正常工作</li>
<li>建议删除现有文档后重新上传</li>
<li>不同嵌入模型生成的向量不兼容</li>
</ul>
<div class="mt-4 text-body-2">
您确定要将嵌入模型从 <strong>{{ originalEmbeddingProvider }}</strong> 修改为 <strong>{{ pendingEmbeddingProvider }}</strong> 吗?
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelEmbeddingChange">
取消
</v-btn>
<v-btn color="warning" variant="elevated" @click="confirmEmbeddingChange">
确认修改
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@@ -452,26 +417,6 @@ const handleEmbeddingProviderChange = (newValue: string | null) => {
}
}
// 确认修改 embedding provider
const confirmEmbeddingChange = () => {
if (pendingEmbeddingProvider.value) {
formData.value.embedding_provider_id = pendingEmbeddingProvider.value
// 更新原始值,这样下次比较时不会重复弹窗
originalEmbeddingProvider.value = pendingEmbeddingProvider.value
}
embeddingChangeDialog.value = false
showEmbeddingWarning.value = true
}
// 取消修改 embedding provider
const cancelEmbeddingChange = () => {
// 恢复到原始值
formData.value.embedding_provider_id = originalEmbeddingProvider.value
embeddingChangeDialog.value = false
showEmbeddingWarning.value = false
pendingEmbeddingProvider.value = null
}
// 确认删除
const confirmDelete = (kb: any) => {
deleteTarget.value = kb
@@ -1,59 +1,57 @@
<template>
<div class="retrieval-tab">
<v-card elevation="2">
<v-card-title class="pa-4">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="px-4 pb-4">
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="pb-4 pt-2">
{{ t('retrieval.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-progress-linear
v-if="loading"
indeterminate
color="primary"
height="2"
/>
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
<v-card-text class="pa-6">
<!-- 查询输入区域 -->
<v-row class="mb-4">
<v-col cols="12" md="8">
<v-textarea
v-model="query"
:label="t('retrieval.query')"
:placeholder="t('retrieval.queryPlaceholder')"
variant="outlined"
rows="3"
auto-grow
clearable
/>
<v-textarea v-model="query" :label="t('retrieval.query')" :placeholder="t('retrieval.queryPlaceholder')"
variant="outlined" rows="3" auto-grow clearable />
<!-- debug -->
<div v-if="debugVisualize" class="mt-2">
<v-card variant="outlined">
<v-img :src="`data:image/png;base64,${debugVisualize}`" :alt="t('retrieval.tsneVisualization')" cover>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" />
</div>
</template>
</v-img>
</v-card>
</div>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4>
<v-text-field
v-model.number="topK"
:label="t('retrieval.topK')"
:hint="t('retrieval.topKHint')"
type="number"
variant="outlined"
density="compact"
persistent-hint
/>
<v-text-field v-model.number="topK" :label="t('retrieval.topK')" :hint="t('retrieval.topKHint')"
type="number" variant="outlined" density="compact" persistent-hint class="mb-3" />
<v-switch v-model="debugMode" :label="t('retrieval.debugMode')" color="primary" density="compact"
hide-details>
<template v-slot:label>
<span class="text-caption">
<v-icon size="small" class="mr-1">mdi-bug</v-icon>
Debug (t-SNE)
</span>
</template>
</v-switch>
</v-card>
</v-col>
</v-row>
<div class="d-flex justify-end mb-4">
<v-btn
prepend-icon="mdi-magnify"
color="primary"
variant="elevated"
@click="performRetrieval"
:loading="loading"
:disabled="!query || query.trim() === ''"
>
<v-btn prepend-icon="mdi-magnify" color="primary" variant="elevated" @click="performRetrieval"
:loading="loading" :disabled="!query || query.trim() === ''">
{{ loading ? t('retrieval.searching') : t('retrieval.search') }}
</v-btn>
</div>
@@ -64,28 +62,33 @@
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
<v-chip class="ml-3" color="primary" variant="tonal">
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
{{ results.length }} {{ t('retrieval.results') }}
</v-chip>
</div>
<!-- 结果列表 -->
<div v-if="results.length > 0" class="results-list">
<v-card
v-for="(result, index) in results"
:key="result.chunk_id"
variant="outlined"
class="mb-4"
>
<v-card-title class="d-flex align-center pa-4">
<v-chip size="small" color="primary" class="mr-2">
<v-card v-for="(result, index) in results" :key="result.chunk_id" variant="outlined" class="mb-4">
<v-card-title class="d-flex align-center pa-2">
<v-chip size="x-small" color="primary" class="mr-2">
#{{ index + 1 }}
</v-chip>
<span class="text-subtitle-1">
{{ t('retrieval.chunk', { index: result.chunk_index }) }}
</span>
<div class="ml-4">
<v-chip size="x-small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-file-document</v-icon>
{{ result.doc_name }}
</v-chip>
<v-chip size="x-small" variant="tonal">
<v-icon start size="small">mdi-text</v-icon>
{{ t('retrieval.charCount', { count: result.char_count }) }}
</v-chip>
</div>
<v-spacer />
<v-chip size="small" :color="getScoreColor(result.score)">
<v-chip size="x-small" :color="getScoreColor(result.score)">
{{ t('retrieval.score') }}: {{ result.score.toFixed(4) }}
</v-chip>
</v-card-title>
@@ -93,17 +96,6 @@
<v-divider />
<v-card-text class="pa-4">
<div class="mb-3">
<v-chip size="small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-file-document</v-icon>
{{ result.doc_name }}
</v-chip>
<v-chip size="small" variant="tonal">
<v-icon start size="small">mdi-text</v-icon>
{{ t('retrieval.charCount', { count: result.char_count }) }}
</v-chip>
</div>
<div class="content-box">
{{ result.content }}
</div>
@@ -144,9 +136,10 @@ const props = defineProps<{
const loading = ref(false)
const query = ref('')
const topK = ref(5)
const enableRerank = ref(false)
const debugMode = ref(false)
const results = ref<any[]>([])
const hasSearched = ref(false)
const debugVisualize = ref<string | null>(null)
const snackbar = ref({
show: false,
@@ -169,18 +162,24 @@ const performRetrieval = async () => {
loading.value = true
hasSearched.value = false
debugVisualize.value = null
try {
const response = await axios.post('/api/kb/retrieve', {
query: query.value,
kb_names: [props.kbName],
top_k: topK.value,
enable_rerank: enableRerank.value
debug: debugMode.value
})
if (response.data.status === 'ok') {
results.value = response.data.data.results || []
hasSearched.value = true
if (debugMode.value && response.data.data.visualization) {
debugVisualize.value = response.data.data.visualization
}
showSnackbar(t('retrieval.searchSuccess', { count: results.value.length }))
} else {
showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error')
@@ -208,8 +207,13 @@ const getScoreColor = (score: number) => {
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.results-section {
@@ -221,6 +225,7 @@ const getScoreColor = (score: number) => {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -228,7 +233,7 @@ const getScoreColor = (score: number) => {
}
.content-box {
background: rgba(var(--v-theme-surface-variant), 0.3);
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 16px;
white-space: pre-wrap;
@@ -236,5 +241,8 @@ const getScoreColor = (score: number) => {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.6;
height: 120px;
overflow-y: auto;
font-size: 13px;
}
</style>