165 lines
5.6 KiB
JavaScript
165 lines
5.6 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
}
|