diff --git a/js/app.js b/js/app.js
index a096aad..3aa69fe 100644
--- a/js/app.js
+++ b/js/app.js
@@ -17,6 +17,8 @@ import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from '.
import { initInputArea } from './components/input-area.js';
import { initSettingsModal, closeSettingsModal } from './components/settings-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());
@@ -88,6 +90,7 @@ async function init() {
initInputArea();
initSettingsModal();
initHistoryModal();
+ initKBModal();
// 5. 绑定全局事件
bindGlobalEvents();
@@ -96,6 +99,9 @@ async function init() {
await checkConnection();
await loadModels();
+ // 6.1 初始化向量存储
+ await initVectorStore();
+
// 7. 恢复上次选择的模型
const savedModel = await db.getSetting('selectedModel', '');
if (savedModel) {
diff --git a/js/components/input-area.js b/js/components/input-area.js
index f125975..f97c2a5 100644
--- a/js/components/input-area.js
+++ b/js/components/input-area.js
@@ -12,6 +12,7 @@ import {
} from './chat-area.js';
import { showToast } from './toast.js';
import { checkConnection } from './header.js';
+import { isRagEnabled, performRagRetrieval } from './kb-modal.js';
let chatInputEl, btnSendEl, fileInputEl, textFileInputEl, imagePreviewEl, filePreviewEl;
let pendingImages = [];
@@ -400,6 +401,16 @@ export async function sendMessage() {
...(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) => {
if (chunk.message) {
if (chunk.message.content) {
diff --git a/js/components/kb-modal.js b/js/components/kb-modal.js
new file mode 100644
index 0000000..1bf518f
--- /dev/null
+++ b/js/components/kb-modal.js
@@ -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 = '
暂无知识库,点击上方「新建集合」创建
';
+ currentColId = null;
+ refreshDocList();
+ return;
+ }
+
+ listEl.innerHTML = collections.map(c => `
+
+
+ ${escapeHtml(c.name)}
+ ${c.docCount || 0} 文档 · ${(c.chunkCount || 0)} 分块
+
+
+
+ `).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 = '
← 选择一个知识库集合
';
+ return;
+ }
+
+ const docs = await getDocumentsInCollection(currentColId);
+ if (docs.length === 0) {
+ docListEl.innerHTML = '
此集合暂无文档,上传文件开始构建知识库
';
+ return;
+ }
+
+ docListEl.innerHTML = docs.map(d => `
+
+
📄
+
+ ${escapeHtml(d.filename)}
+ ${d.chunkCount} 个分块
+
+
+
+ `).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 = '
';
+ 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);
+ }
+}
diff --git a/js/document-processor.js b/js/document-processor.js
new file mode 100644
index 0000000..e8d4cc9
--- /dev/null
+++ b/js/document-processor.js
@@ -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
+ };
+}
diff --git a/js/rag.js b/js/rag.js
new file mode 100644
index 0000000..904c04a
--- /dev/null
+++ b/js/rag.js
@@ -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;
+}
diff --git a/js/vector-store.js b/js/vector-store.js
new file mode 100644
index 0000000..70141f1
--- /dev/null
+++ b/js/vector-store.js
@@ -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;
+ }
+}