feat: RAG 本地知识库 - 向量存储、文档分块、语义检索、知识库管理面板
This commit is contained in:
+209
@@ -1712,3 +1712,212 @@ body::before {
|
|||||||
from { opacity: 1; transform: translateX(0); }
|
from { opacity: 1; transform: translateX(0); }
|
||||||
to { opacity: 0; transform: translateX(40px); }
|
to { opacity: 0; transform: translateX(40px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════ 知识库管理 ═══════════════ */
|
||||||
|
|
||||||
|
.modal-lg {
|
||||||
|
max-width: 780px;
|
||||||
|
width: 95vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-sidebar {
|
||||||
|
width: 260px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-new-collection {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.kb-new-collection .setting-input {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-collection-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-collection-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.kb-collection-item:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.kb-collection-item.active {
|
||||||
|
background: rgba(0,245,212,0.08);
|
||||||
|
border-color: rgba(0,245,212,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-col-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.kb-col-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.kb-col-stats {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-col-delete {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.kb-collection-item:hover .kb-col-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.kb-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.kb-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-progress {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(0,245,212,0.3);
|
||||||
|
border-top-color: var(--accent-cyan);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-embed-model {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.kb-embed-model .model-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-doc-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-doc-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.kb-doc-item:hover {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-doc-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.kb-doc-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.kb-doc-name {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.kb-doc-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-doc-delete {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.kb-doc-item:hover .kb-doc-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RAG badge */
|
||||||
|
.rag-badge {
|
||||||
|
background: rgba(123,47,247,0.15) !important;
|
||||||
|
color: #b388ff !important;
|
||||||
|
border-color: rgba(123,47,247,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.kb-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.kb-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+66
@@ -32,6 +32,14 @@
|
|||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="icon-btn" id="btnKB" title="知识库">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
|
||||||
|
<line x1="8" y1="7" x2="16" y2="7"/>
|
||||||
|
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="icon-btn" id="btnHistory" title="历史记录">
|
<button class="icon-btn" id="btnHistory" title="历史记录">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
@@ -64,6 +72,7 @@
|
|||||||
<div class="model-badges" id="modelBadges">
|
<div class="model-badges" id="modelBadges">
|
||||||
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
|
<span class="model-badge think-badge" id="badgeThink" style="display:none;">🧠 Think</span>
|
||||||
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
|
<span class="model-badge vision-badge" id="badgeVision" style="display:none;">👁️ Vision</span>
|
||||||
|
<span class="model-badge rag-badge" id="badgeRAG" style="display:none;">📚 RAG</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,6 +234,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════ 知识库管理模态框 ═══════════════ -->
|
||||||
|
<div class="modal-overlay" id="kbModal" style="display:none;">
|
||||||
|
<div class="modal modal-lg">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>📚 知识库 (RAG)</h3>
|
||||||
|
<button class="icon-btn" id="btnCloseKB">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="kb-layout">
|
||||||
|
<!-- 左侧:集合列表 -->
|
||||||
|
<div class="kb-sidebar">
|
||||||
|
<div class="kb-new-collection">
|
||||||
|
<input class="setting-input" id="inputKBName" type="text" placeholder="新建知识库名称...">
|
||||||
|
<button class="btn btn-sm btn-primary" id="btnNewCollection">创建</button>
|
||||||
|
</div>
|
||||||
|
<div class="kb-collection-list" id="kbCollectionList">
|
||||||
|
<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 右侧:文档列表 -->
|
||||||
|
<div class="kb-main">
|
||||||
|
<div class="kb-toolbar">
|
||||||
|
<div class="kb-toolbar-left">
|
||||||
|
<button class="btn btn-sm btn-outline" id="btnUploadDoc">📄 上传文档</button>
|
||||||
|
<input type="file" id="kbFileInput" accept=".txt,.md,.py,.js,.ts,.java,.go,.rs,.cpp,.c,.h,.hpp,.rb,.php,.sh,.html,.css,.json,.yaml,.yml,.toml,.xml,.sql,.csv" multiple style="display:none;">
|
||||||
|
<span id="kbProgress" style="display:none;" class="kb-progress">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span id="kbProgressText">处理中...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="kb-toolbar-right">
|
||||||
|
<label class="setting-label" style="margin:0;font-size:12px;">RAG 检索</label>
|
||||||
|
<label class="toggle-label" style="margin:0;">
|
||||||
|
<input type="checkbox" id="toggleRAG">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kb-embed-model">
|
||||||
|
<label class="setting-label" style="font-size:12px;">嵌入模型</label>
|
||||||
|
<select class="model-select" id="selectEmbedModel" style="font-size:13px;">
|
||||||
|
<option value="">选择嵌入模型...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="kb-doc-list" id="kbDocList">
|
||||||
|
<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
|
<!-- ═══════════════ 图片预览 Lightbox ═══════════════ -->
|
||||||
<div class="lightbox-overlay" id="lightbox" style="display:none;">
|
<div class="lightbox-overlay" id="lightbox" style="display:none;">
|
||||||
<button class="lightbox-close" id="lightboxClose">✕</button>
|
<button class="lightbox-close" id="lightboxClose">✕</button>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from '.
|
|||||||
import { initInputArea } from './components/input-area.js';
|
import { initInputArea } from './components/input-area.js';
|
||||||
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
||||||
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
||||||
|
import { initKBModal } from './components/kb-modal.js';
|
||||||
|
import { initVectorStore } from './rag.js';
|
||||||
|
|
||||||
console.log('[Metona] 模块化版本 v2.2.0 ' + new Date().toISOString());
|
console.log('[Metona] 模块化版本 v2.2.0 ' + new Date().toISOString());
|
||||||
|
|
||||||
@@ -88,6 +90,7 @@ async function init() {
|
|||||||
initInputArea();
|
initInputArea();
|
||||||
initSettingsModal();
|
initSettingsModal();
|
||||||
initHistoryModal();
|
initHistoryModal();
|
||||||
|
initKBModal();
|
||||||
|
|
||||||
// 5. 绑定全局事件
|
// 5. 绑定全局事件
|
||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
@@ -96,6 +99,9 @@ async function init() {
|
|||||||
await checkConnection();
|
await checkConnection();
|
||||||
await loadModels();
|
await loadModels();
|
||||||
|
|
||||||
|
// 6.1 初始化向量存储
|
||||||
|
await initVectorStore();
|
||||||
|
|
||||||
// 7. 恢复上次选择的模型
|
// 7. 恢复上次选择的模型
|
||||||
const savedModel = await db.getSetting('selectedModel', '');
|
const savedModel = await db.getSetting('selectedModel', '');
|
||||||
if (savedModel) {
|
if (savedModel) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from './chat-area.js';
|
} from './chat-area.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { checkConnection } from './header.js';
|
import { checkConnection } from './header.js';
|
||||||
|
import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
|
||||||
|
|
||||||
let chatInputEl, btnSendEl, fileInputEl, textFileInputEl, imagePreviewEl, filePreviewEl;
|
let chatInputEl, btnSendEl, fileInputEl, textFileInputEl, imagePreviewEl, filePreviewEl;
|
||||||
let pendingImages = [];
|
let pendingImages = [];
|
||||||
@@ -400,6 +401,16 @@ export async function sendMessage() {
|
|||||||
...(numCtx && { options: { num_ctx: numCtx } })
|
...(numCtx && { options: { num_ctx: numCtx } })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// RAG 检索增强
|
||||||
|
if (isRagEnabled()) {
|
||||||
|
const ragResult = await performRagRetrieval(text);
|
||||||
|
if (ragResult && ragResult.ragPrompt) {
|
||||||
|
chatParams.system = chatParams.system
|
||||||
|
? chatParams.system + '\n\n' + ragResult.ragPrompt
|
||||||
|
: ragResult.ragPrompt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await api.chatStream(chatParams, (chunk) => {
|
await api.chatStream(chatParams, (chunk) => {
|
||||||
if (chunk.message) {
|
if (chunk.message) {
|
||||||
if (chunk.message.content) {
|
if (chunk.message.content) {
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* KBModal - 知识库管理面板
|
||||||
|
*
|
||||||
|
* 支持:创建/删除集合、上传/删除文档、选择嵌入模型、启用 RAG 检索
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state, KEYS } from '../state.js';
|
||||||
|
import {
|
||||||
|
initVectorStore, getVectorStore, addDocumentToKB,
|
||||||
|
removeDocumentFromKB, getDocumentsInCollection, retrieveContext,
|
||||||
|
buildRagSystemPrompt
|
||||||
|
} from '../rag.js';
|
||||||
|
import { fileToText, formatSize, escapeHtml, truncate } from '../utils.js';
|
||||||
|
import { showToast } from './toast.js';
|
||||||
|
|
||||||
|
let kbModalEl;
|
||||||
|
let currentColId = null;
|
||||||
|
|
||||||
|
/** 当前是否启用 RAG */
|
||||||
|
let ragEnabled = false;
|
||||||
|
let ragCollectionId = null;
|
||||||
|
|
||||||
|
export function initKBModal() {
|
||||||
|
kbModalEl = document.querySelector('#kbModal');
|
||||||
|
|
||||||
|
document.querySelector('#btnKB').addEventListener('click', openKBModal);
|
||||||
|
document.querySelector('#btnCloseKB').addEventListener('click', closeKBModal);
|
||||||
|
kbModalEl.addEventListener('click', (e) => {
|
||||||
|
if (e.target === kbModalEl) closeKBModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 新建集合
|
||||||
|
document.querySelector('#btnNewCollection').addEventListener('click', createCollection);
|
||||||
|
|
||||||
|
// 上传文档
|
||||||
|
document.querySelector('#btnUploadDoc').addEventListener('click', () => {
|
||||||
|
if (!currentColId) {
|
||||||
|
showToast('请先选择一个知识库集合', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.querySelector('#kbFileInput').click();
|
||||||
|
});
|
||||||
|
document.querySelector('#kbFileInput').addEventListener('change', handleDocUpload);
|
||||||
|
|
||||||
|
// RAG 开关
|
||||||
|
document.querySelector('#toggleRAG').addEventListener('change', (e) => {
|
||||||
|
ragEnabled = e.target.checked;
|
||||||
|
if (ragEnabled && currentColId) {
|
||||||
|
ragCollectionId = currentColId;
|
||||||
|
showToast('RAG 知识检索已开启', 'success');
|
||||||
|
} else if (ragEnabled && !currentColId) {
|
||||||
|
e.target.checked = false;
|
||||||
|
ragEnabled = false;
|
||||||
|
showToast('请先选择一个知识库集合', 'warning');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
ragCollectionId = null;
|
||||||
|
showToast('RAG 知识检索已关闭', 'info');
|
||||||
|
}
|
||||||
|
updateRagBadge();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 嵌入模型变更
|
||||||
|
document.querySelector('#selectEmbedModel').addEventListener('change', async (e) => {
|
||||||
|
if (!currentColId) return;
|
||||||
|
const vs = getVectorStore();
|
||||||
|
const col = await vs.getCollection(currentColId);
|
||||||
|
if (col) {
|
||||||
|
col.embeddingModel = e.target.value;
|
||||||
|
await vs.updateCollection(col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RAG 接口 ──
|
||||||
|
|
||||||
|
export function isRagEnabled() {
|
||||||
|
return ragEnabled && !!ragCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRagCollectionId() {
|
||||||
|
return ragCollectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performRagRetrieval(query) {
|
||||||
|
if (!isRagEnabled()) return null;
|
||||||
|
try {
|
||||||
|
const { context, results } = await retrieveContext(query, ragCollectionId, 5);
|
||||||
|
if (!context) return null;
|
||||||
|
return { context, results, ragPrompt: buildRagSystemPrompt(context) };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[KB] RAG 检索失败:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UI ──
|
||||||
|
|
||||||
|
function updateRagBadge() {
|
||||||
|
const badge = document.querySelector('#badgeRAG');
|
||||||
|
if (!badge) return;
|
||||||
|
badge.style.display = ragEnabled ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openKBModal() {
|
||||||
|
kbModalEl.style.display = '';
|
||||||
|
await refreshCollections();
|
||||||
|
await populateEmbedModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeKBModal() {
|
||||||
|
kbModalEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新集合列表
|
||||||
|
*/
|
||||||
|
async function refreshCollections() {
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
const collections = await vs.getCollections();
|
||||||
|
const listEl = document.querySelector('#kbCollectionList');
|
||||||
|
|
||||||
|
if (collections.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">暂无知识库,点击上方「新建集合」创建</p>';
|
||||||
|
currentColId = null;
|
||||||
|
refreshDocList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = collections.map(c => `
|
||||||
|
<div class="kb-collection-item ${c.id === currentColId ? 'active' : ''}" data-id="${c.id}">
|
||||||
|
<div class="kb-col-info">
|
||||||
|
<span class="kb-col-name">${escapeHtml(c.name)}</span>
|
||||||
|
<span class="kb-col-stats">${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块</span>
|
||||||
|
</div>
|
||||||
|
<button class="kb-col-delete icon-btn" data-id="${c.id}" title="删除集合">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// 点击选中集合
|
||||||
|
listEl.querySelectorAll('.kb-collection-item').forEach(el => {
|
||||||
|
el.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.closest('.kb-col-delete')) return;
|
||||||
|
currentColId = el.dataset.id;
|
||||||
|
await refreshCollections();
|
||||||
|
await refreshDocList();
|
||||||
|
// 设置嵌入模型下拉
|
||||||
|
const vs = getVectorStore();
|
||||||
|
const col = await vs.getCollection(currentColId);
|
||||||
|
if (col?.embeddingModel) {
|
||||||
|
document.querySelector('#selectEmbedModel').value = col.embeddingModel;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除集合
|
||||||
|
listEl.querySelectorAll('.kb-col-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
if (!confirm('确定删除此知识库集合?所有文档将被清除。')) return;
|
||||||
|
const vs = getVectorStore();
|
||||||
|
await vs.deleteCollection(id);
|
||||||
|
if (currentColId === id) currentColId = null;
|
||||||
|
if (ragCollectionId === id) {
|
||||||
|
ragEnabled = false;
|
||||||
|
ragCollectionId = null;
|
||||||
|
document.querySelector('#toggleRAG').checked = false;
|
||||||
|
updateRagBadge();
|
||||||
|
}
|
||||||
|
showToast('集合已删除', 'success');
|
||||||
|
await refreshCollections();
|
||||||
|
await refreshDocList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新文档列表
|
||||||
|
*/
|
||||||
|
async function refreshDocList() {
|
||||||
|
const docListEl = document.querySelector('#kbDocList');
|
||||||
|
if (!currentColId) {
|
||||||
|
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">← 选择一个知识库集合</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = await getDocumentsInCollection(currentColId);
|
||||||
|
if (docs.length === 0) {
|
||||||
|
docListEl.innerHTML = '<p class="text-muted" style="padding:16px;text-align:center;">此集合暂无文档,上传文件开始构建知识库</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
docListEl.innerHTML = docs.map(d => `
|
||||||
|
<div class="kb-doc-item">
|
||||||
|
<span class="kb-doc-icon">📄</span>
|
||||||
|
<div class="kb-doc-info">
|
||||||
|
<span class="kb-doc-name">${escapeHtml(d.filename)}</span>
|
||||||
|
<span class="kb-doc-meta">${d.chunkCount} 个分块</span>
|
||||||
|
</div>
|
||||||
|
<button class="kb-doc-delete icon-btn" data-docid="${d.docId}" title="删除文档">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
docListEl.querySelectorAll('.kb-doc-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const docId = btn.dataset.docid;
|
||||||
|
if (!confirm('确定删除此文档?')) return;
|
||||||
|
await removeDocumentFromKB(currentColId, docId);
|
||||||
|
showToast('文档已删除', 'success');
|
||||||
|
await refreshCollections();
|
||||||
|
await refreshDocList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新集合
|
||||||
|
*/
|
||||||
|
async function createCollection() {
|
||||||
|
const nameInput = document.querySelector('#inputKBName');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showToast('请输入知识库名称', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
const col = await vs.createCollection(name);
|
||||||
|
currentColId = col.id;
|
||||||
|
nameInput.value = '';
|
||||||
|
showToast(`知识库「${name}」已创建`, 'success');
|
||||||
|
await refreshCollections();
|
||||||
|
await refreshDocList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文档处理
|
||||||
|
*/
|
||||||
|
async function handleDocUpload(e) {
|
||||||
|
const files = e.target.value;
|
||||||
|
e.target.value = '';
|
||||||
|
|
||||||
|
const embedModel = document.querySelector('#selectEmbedModel').value;
|
||||||
|
if (!embedModel) {
|
||||||
|
showToast('请先选择一个嵌入模型', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileArr = Array.from(e.target.files || []);
|
||||||
|
if (fileArr.length === 0) return;
|
||||||
|
|
||||||
|
const progressEl = document.querySelector('#kbProgress');
|
||||||
|
const progressTextEl = document.querySelector('#kbProgressText');
|
||||||
|
|
||||||
|
for (const file of fileArr) {
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showToast(`${file.name} 超过 5MB 限制`, 'warning');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
progressEl.style.display = '';
|
||||||
|
progressTextEl.textContent = `正在处理 ${file.name}...`;
|
||||||
|
|
||||||
|
const content = await fileToText(file);
|
||||||
|
await addDocumentToKB(
|
||||||
|
currentColId, file.name, content, embedModel,
|
||||||
|
(done, total, msg) => {
|
||||||
|
progressTextEl.textContent = `${file.name}: ${msg} (${done}/${total})`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
showToast(`${file.name} 已添加到知识库`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[KB] 文档处理失败:', err);
|
||||||
|
showToast(`${file.name} 处理失败: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressEl.style.display = 'none';
|
||||||
|
await refreshCollections();
|
||||||
|
await refreshDocList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充嵌入模型下拉
|
||||||
|
*/
|
||||||
|
async function populateEmbedModels() {
|
||||||
|
const select = document.querySelector('#selectEmbedModel');
|
||||||
|
const api = state.get(KEYS.API);
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.listModels();
|
||||||
|
select.innerHTML = '<option value="">选择嵌入模型...</option>';
|
||||||
|
if (data.models) {
|
||||||
|
// 推荐的嵌入模型优先显示
|
||||||
|
const embedKeywords = ['embed', 'nomic', 'mxbai', 'bge', 'e5', 'snowflake'];
|
||||||
|
const recommended = [];
|
||||||
|
const others = [];
|
||||||
|
|
||||||
|
for (const m of data.models) {
|
||||||
|
const name = m.name.toLowerCase();
|
||||||
|
if (embedKeywords.some(kw => name.includes(kw))) {
|
||||||
|
recommended.push(m);
|
||||||
|
} else {
|
||||||
|
others.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommended.length > 0) {
|
||||||
|
const optgroup = document.createElement('optgroup');
|
||||||
|
optgroup.label = '推荐(嵌入模型)';
|
||||||
|
recommended.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.name;
|
||||||
|
opt.textContent = m.name;
|
||||||
|
optgroup.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.appendChild(optgroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const optgroup2 = document.createElement('optgroup');
|
||||||
|
optgroup2.label = '其他模型';
|
||||||
|
others.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.name;
|
||||||
|
opt.textContent = m.name;
|
||||||
|
optgroup2.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.appendChild(optgroup2);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[KB] 加载模型列表失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* DocumentProcessor - 文档分块处理
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文本切分为语义块
|
||||||
|
* @param {string} text - 原始文本
|
||||||
|
* @param {object} options
|
||||||
|
* @param {number} options.maxChunkSize - 每块最大字符数 (默认 1500)
|
||||||
|
* @param {number} options.overlap - 块间重叠字符数 (默认 200)
|
||||||
|
* @returns {string[]} 分块后的文本数组
|
||||||
|
*/
|
||||||
|
export function chunkText(text, { maxChunkSize = 1500, overlap = 200 } = {}) {
|
||||||
|
if (!text || text.trim().length === 0) return [];
|
||||||
|
|
||||||
|
// 先按段落分割
|
||||||
|
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
|
||||||
|
const chunks = [];
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
const trimmed = para.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// 单段落超过 maxChunkSize → 按句子分割
|
||||||
|
if (trimmed.length > maxChunkSize) {
|
||||||
|
if (current) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
}
|
||||||
|
const sentences = splitSentences(trimmed);
|
||||||
|
let sentenceBuf = '';
|
||||||
|
for (const sent of sentences) {
|
||||||
|
if ((sentenceBuf + sent).length > maxChunkSize && sentenceBuf) {
|
||||||
|
chunks.push(sentenceBuf.trim());
|
||||||
|
// 保留末尾 overlap
|
||||||
|
sentenceBuf = overlap > 0
|
||||||
|
? sentenceBuf.slice(-overlap) + sent
|
||||||
|
: sent;
|
||||||
|
} else {
|
||||||
|
sentenceBuf += sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sentenceBuf.trim()) {
|
||||||
|
current = sentenceBuf;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常段落累积
|
||||||
|
if ((current + '\n\n' + trimmed).length > maxChunkSize && current) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
// 保留末尾 overlap
|
||||||
|
if (overlap > 0 && current.length > overlap) {
|
||||||
|
current = current.slice(-overlap) + '\n\n' + trimmed;
|
||||||
|
} else {
|
||||||
|
current = trimmed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = current ? current + '\n\n' + trimmed : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.trim()) {
|
||||||
|
chunks.push(current.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按句子分割(中英文兼容)
|
||||||
|
*/
|
||||||
|
function splitSentences(text) {
|
||||||
|
// 中文句号、问号、感叹号 + 英文句号等
|
||||||
|
const parts = text.split(/(?<=[。!?.!?])\s*/);
|
||||||
|
return parts.filter(p => p.trim()).map(p => p.trim() + ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件内容提取文档信息
|
||||||
|
* @param {string} content - 文件内容
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
* @returns {{ text: string, filename: string }}
|
||||||
|
*/
|
||||||
|
export function extractDocument(content, filename) {
|
||||||
|
return {
|
||||||
|
text: content,
|
||||||
|
filename
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成文档块的元数据
|
||||||
|
*/
|
||||||
|
export function createChunkMetadata(chunk, index, docId, filename) {
|
||||||
|
return {
|
||||||
|
id: `${docId}_chunk_${index}`,
|
||||||
|
docId,
|
||||||
|
filename,
|
||||||
|
chunkIndex: index,
|
||||||
|
text: chunk,
|
||||||
|
charCount: chunk.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* RAG - 检索增强生成管线
|
||||||
|
*
|
||||||
|
* 查询 → 嵌入 → 向量检索 → 构造上下文 → 注入 prompt
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state, KEYS } from './state.js';
|
||||||
|
import { VectorStore } from './vector-store.js';
|
||||||
|
import { chunkText, createChunkMetadata } from './document-processor.js';
|
||||||
|
import { showToast } from './components/toast.js';
|
||||||
|
|
||||||
|
let vectorStore = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化向量存储
|
||||||
|
*/
|
||||||
|
export async function initVectorStore() {
|
||||||
|
if (vectorStore) return vectorStore;
|
||||||
|
vectorStore = new VectorStore();
|
||||||
|
await vectorStore.init();
|
||||||
|
return vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取向量存储实例
|
||||||
|
*/
|
||||||
|
export function getVectorStore() {
|
||||||
|
return vectorStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Ollama 生成文本嵌入向量
|
||||||
|
* @param {string} text
|
||||||
|
* @param {string} model - 嵌入模型名称
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
export async function embedText(text, model) {
|
||||||
|
const api = state.get(KEYS.API);
|
||||||
|
if (!api) throw new Error('Ollama API 未连接');
|
||||||
|
|
||||||
|
const result = await api.embed(model, text);
|
||||||
|
// /api/embed 返回 { embedding: [...] } 或 { embeddings: [[...]] }
|
||||||
|
if (result.embedding) return result.embedding;
|
||||||
|
if (result.embeddings && result.embeddings[0]) return result.embeddings[0];
|
||||||
|
throw new Error('嵌入向量返回格式异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量嵌入(分批处理,避免超时)
|
||||||
|
* @param {string[]} texts
|
||||||
|
* @param {string} model
|
||||||
|
* @param {function} onProgress - (done, total) => void
|
||||||
|
* @returns {number[][]}
|
||||||
|
*/
|
||||||
|
export async function embedBatch(texts, model, onProgress) {
|
||||||
|
const results = [];
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < texts.length; i += batchSize) {
|
||||||
|
const batch = texts.slice(i, i + batchSize);
|
||||||
|
const embeddings = await Promise.all(batch.map(t => embedText(t, model)));
|
||||||
|
results.push(...embeddings);
|
||||||
|
if (onProgress) onProgress(Math.min(i + batchSize, texts.length), texts.length);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向知识库集合添加文档
|
||||||
|
* @param {string} colId - 集合 ID
|
||||||
|
* @param {string} filename - 文件名
|
||||||
|
* @param {string} content - 文件内容
|
||||||
|
* @param {string} embedModel - 嵌入模型
|
||||||
|
* @param {function} onProgress
|
||||||
|
*/
|
||||||
|
export async function addDocumentToKB(colId, filename, content, embedModel, onProgress) {
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
|
||||||
|
// 1. 分块
|
||||||
|
const textChunks = chunkText(content);
|
||||||
|
if (textChunks.length === 0) throw new Error('文档内容为空,无法处理');
|
||||||
|
|
||||||
|
// 2. 生成文档 ID
|
||||||
|
const docId = `doc_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// 3. 生成嵌入向量
|
||||||
|
if (onProgress) onProgress(0, textChunks.length, '正在生成向量...');
|
||||||
|
const embeddings = await embedBatch(textChunks, embedModel, (done, total) => {
|
||||||
|
if (onProgress) onProgress(done, total, '正在生成向量...');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 构造向量数据
|
||||||
|
const vectors = textChunks.map((chunk, i) => {
|
||||||
|
const meta = createChunkMetadata(chunk, i, docId, filename);
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
collectionId: colId,
|
||||||
|
embedding: embeddings[i]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 存储
|
||||||
|
if (onProgress) onProgress(textChunks.length, textChunks.length, '正在保存...');
|
||||||
|
await vs.addVectors(vectors);
|
||||||
|
|
||||||
|
// 6. 更新集合统计
|
||||||
|
const col = await vs.getCollection(colId);
|
||||||
|
if (col) {
|
||||||
|
col.docCount = (col.docCount || 0) + 1;
|
||||||
|
col.chunkCount = (col.chunkCount || 0) + textChunks.length;
|
||||||
|
col.embeddingModel = embedModel;
|
||||||
|
await vs.updateCollection(col);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { docId, chunkCount: textChunks.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从知识库删除文档
|
||||||
|
*/
|
||||||
|
export async function removeDocumentFromKB(colId, docId) {
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
const allVectors = await vs.getVectorsByCollection(colId);
|
||||||
|
const docVectors = allVectors.filter(v => v.docId === docId);
|
||||||
|
|
||||||
|
await vs.deleteVectorsByDocument(colId, docId);
|
||||||
|
|
||||||
|
const col = await vs.getCollection(colId);
|
||||||
|
if (col) {
|
||||||
|
col.chunkCount = Math.max(0, (col.chunkCount || 0) - docVectors.length);
|
||||||
|
col.docCount = Math.max(0, (col.docCount || 0) - 1);
|
||||||
|
await vs.updateCollection(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取集合中的文档列表(去重)
|
||||||
|
*/
|
||||||
|
export async function getDocumentsInCollection(colId) {
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
const vectors = await vs.getVectorsByCollection(colId);
|
||||||
|
const docMap = new Map();
|
||||||
|
for (const v of vectors) {
|
||||||
|
if (!docMap.has(v.docId)) {
|
||||||
|
docMap.set(v.docId, {
|
||||||
|
docId: v.docId,
|
||||||
|
filename: v.filename,
|
||||||
|
chunkCount: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
docMap.get(v.docId).chunkCount++;
|
||||||
|
}
|
||||||
|
return Array.from(docMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG 检索:查询 → 嵌入 → 相似度搜索 → 返回上下文文本
|
||||||
|
* @param {string} query - 用户查询
|
||||||
|
* @param {string} colId - 集合 ID
|
||||||
|
* @param {number} topK - 返回最相关的 K 条
|
||||||
|
* @returns {{ context: string, results: Array }}
|
||||||
|
*/
|
||||||
|
export async function retrieveContext(query, colId, topK = 5) {
|
||||||
|
const vs = await initVectorStore();
|
||||||
|
const col = await vs.getCollection(colId);
|
||||||
|
if (!col) throw new Error('知识库集合不存在');
|
||||||
|
|
||||||
|
const embedModel = col.embeddingModel;
|
||||||
|
if (!embedModel) throw new Error('未配置嵌入模型');
|
||||||
|
|
||||||
|
// 嵌入查询
|
||||||
|
const queryEmbedding = await embedText(query, embedModel);
|
||||||
|
|
||||||
|
// 向量检索
|
||||||
|
const results = await vs.search(colId, queryEmbedding, topK);
|
||||||
|
if (results.length === 0) return { context: '', results: [] };
|
||||||
|
|
||||||
|
// 构造上下文
|
||||||
|
const contextParts = results.map((r, i) =>
|
||||||
|
`[来源 ${i + 1}: ${r.filename}]\n${r.text}`
|
||||||
|
);
|
||||||
|
const context = contextParts.join('\n\n---\n\n');
|
||||||
|
|
||||||
|
return { context, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 RAG 增强的系统提示词
|
||||||
|
*/
|
||||||
|
export function buildRagSystemPrompt(context, originalSystemPrompt = '') {
|
||||||
|
const ragPrompt = `你是一个知识库问答助手。以下是检索到的相关文档片段,请基于这些内容回答用户的问题。如果检索到的内容无法回答问题,请如实说明。
|
||||||
|
|
||||||
|
=== 检索到的相关内容 ===
|
||||||
|
${context}
|
||||||
|
=== 内容结束 ===
|
||||||
|
|
||||||
|
请基于以上内容回答用户的问题。引用时请注明来源编号(如"来源 1")。`;
|
||||||
|
|
||||||
|
if (originalSystemPrompt) {
|
||||||
|
return originalSystemPrompt + '\n\n' + ragPrompt;
|
||||||
|
}
|
||||||
|
return ragPrompt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* VectorStore - 向量存储与相似度检索
|
||||||
|
*
|
||||||
|
* 基于 IndexedDB 持久化,支持余弦相似度搜索
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class VectorStore {
|
||||||
|
constructor(dbName = 'metona-ollama-vectors') {
|
||||||
|
this.dbName = dbName;
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(this.dbName, 1);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
req.onsuccess = () => { this.db = req.result; resolve(); };
|
||||||
|
req.onupgradeneeded = (e) => {
|
||||||
|
const db = e.target.result;
|
||||||
|
if (!db.objectStoreNames.contains('vectors')) {
|
||||||
|
const store = db.createObjectStore('vectors', { keyPath: 'id' });
|
||||||
|
store.createIndex('collectionId', 'collectionId', { unique: false });
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains('collections')) {
|
||||||
|
db.createObjectStore('collections', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_tx(store, mode = 'readonly') {
|
||||||
|
return this.db.transaction(store, mode).objectStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 集合管理 ──
|
||||||
|
|
||||||
|
async createCollection(name, embeddingModel = '') {
|
||||||
|
const col = {
|
||||||
|
id: `kb_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
name,
|
||||||
|
embeddingModel,
|
||||||
|
docCount: 0,
|
||||||
|
chunkCount: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
};
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = this._tx('collections', 'readwrite').put(col);
|
||||||
|
req.onsuccess = () => resolve(col);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCollections() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = this._tx('collections').getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result || []);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCollection(id) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = this._tx('collections').get(id);
|
||||||
|
req.onsuccess = () => resolve(req.result || null);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCollection(col) {
|
||||||
|
col.updatedAt = Date.now();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = this._tx('collections', 'readwrite').put(col);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCollection(colId) {
|
||||||
|
// 删除集合下所有向量
|
||||||
|
const vectors = await this.getVectorsByCollection(colId);
|
||||||
|
const store = this._tx('vectors', 'readwrite');
|
||||||
|
for (const v of vectors) store.delete(v.id);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = this._tx('collections', 'readwrite').delete(colId);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 向量操作 ──
|
||||||
|
|
||||||
|
async addVectors(items) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db.transaction('vectors', 'readwrite');
|
||||||
|
const store = tx.objectStore('vectors');
|
||||||
|
for (const item of items) store.put(item);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVectorsByCollection(colId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const idx = this._tx('vectors').index('collectionId');
|
||||||
|
const req = idx.getAll(colId);
|
||||||
|
req.onsuccess = () => resolve(req.result || []);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVectorsByCollection(colId) {
|
||||||
|
const vectors = await this.getVectorsByCollection(colId);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db.transaction('vectors', 'readwrite');
|
||||||
|
const store = tx.objectStore('vectors');
|
||||||
|
for (const v of vectors) store.delete(v.id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVectorsByDocument(colId, docId) {
|
||||||
|
const vectors = await this.getVectorsByCollection(colId);
|
||||||
|
const docVectors = vectors.filter(v => v.docId === docId);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = this.db.transaction('vectors', 'readwrite');
|
||||||
|
const store = tx.objectStore('vectors');
|
||||||
|
for (const v of docVectors) store.delete(v.id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 相似度检索 ──
|
||||||
|
|
||||||
|
async search(colId, queryEmbedding, topK = 5) {
|
||||||
|
const vectors = await this.getVectorsByCollection(colId);
|
||||||
|
if (vectors.length === 0) return [];
|
||||||
|
|
||||||
|
return vectors
|
||||||
|
.map(v => ({
|
||||||
|
...v,
|
||||||
|
score: VectorStore.cosineSimilarity(queryEmbedding, v.embedding)
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, topK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 余弦相似度
|
||||||
|
*/
|
||||||
|
static cosineSimilarity(a, b) {
|
||||||
|
if (!a || !b || a.length !== b.length) return 0;
|
||||||
|
let dot = 0, normA = 0, normB = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i];
|
||||||
|
normA += a[i] * a[i];
|
||||||
|
normB += b[i] * b[i];
|
||||||
|
}
|
||||||
|
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||||
|
return denom === 0 ? 0 : dot / denom;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user