@nataliapc/mcp-openmsx
Version:
Model context protocol server for openMSX automation and control
121 lines (120 loc) • 4.38 kB
JavaScript
/**
* Vector Database wrapper class.
*
* Hybrid search over the MSX documentation corpus:
* - dense vector search (cosine over 384-d embeddings, see embedder.ts)
* - full-text search (BM25, LanceDB native FTS index)
* - fused with Reciprocal Rank Fusion (RRF)
*
* Storage: LanceDB (columnar `.lance` table), replacing the previous Vectra
* `index.json`.
*
* @author Natalia Pujol Cremades (@nataliapc)
* @license GPL2
*/
import path from 'path';
import { pathToFileURL } from 'url';
import * as lancedb from '@lancedb/lancedb';
import { embedQuery } from './embedder.js';
const TABLE_NAME = 'msxdocs';
const TOP_K = 10; // final results returned
const CANDIDATES = 30; // candidates fetched per branch before fusion
const RRF_K = 60; // RRF constant
/**
* Fuse two ranked result lists with Reciprocal Rank Fusion.
* Each document scores Σ 1/(k + rank) over the lists it appears in
* (rank is 0-based, so the +1 makes the top item contribute 1/(k+1)).
*/
export function fuseRRF(vecRows, ftsRows, k = RRF_K, topK = TOP_K) {
const acc = new Map();
const add = (rows) => {
rows.forEach((row, rank) => {
const id = String(row.id);
const inc = 1 / (k + rank + 1);
const cur = acc.get(id);
if (cur) {
cur.score += inc;
}
else {
acc.set(id, { score: inc, row });
}
});
};
add(vecRows);
add(ftsRows);
return [...acc.values()].sort((a, b) => b.score - a.score).slice(0, topK);
}
export class VectorDB {
static instance = null;
static vectorDbDir = path.join('..', 'vector-db');
tablePromise = null;
static getInstance() {
if (!VectorDB.instance) {
VectorDB.instance = new VectorDB();
}
return VectorDB.instance;
}
static setIndexDirectory(dbDir) {
VectorDB.vectorDbDir = dbDir;
}
/**
* Resolve the directory into a value LanceDB can open.
*
* On Windows, LanceDB 0.30 (lance-io 7.0) mishandles drive-letter paths: it
* builds a malformed `file://` URL that drops the drive
* (`file:///mcp-server/vector-db/...`) and then fails to convert it back to a
* filesystem path. Passing an explicit, well-formed `file://` URI built by Node
* (`file:///M:/mcp-server/vector-db`) bypasses that broken path→URL step. On
* POSIX a plain path works fine, so we leave it untouched.
*/
static resolveUri(dir) {
return process.platform === 'win32'
? pathToFileURL(path.resolve(dir)).href
: dir;
}
getTable() {
if (!this.tablePromise) {
this.tablePromise = (async () => {
const db = await lancedb.connect(VectorDB.resolveUri(VectorDB.vectorDbDir));
return db.openTable(TABLE_NAME);
})().catch((err) => {
this.tablePromise = null;
throw new Error(`Failed to open LanceDB table '${TABLE_NAME}' at '${VectorDB.vectorDbDir}'. ` +
`Has the index been generated? (${err instanceof Error ? err.message : err})`);
});
}
return this.tablePromise;
}
async query(text) {
const tbl = await this.getTable();
const vector = await embedQuery(text);
// Vector branch (always available). No `.select()`: scoring queries warn
// when output columns are projected without `_distance`/`_score`, and the
// candidate set is tiny so fetching full rows is negligible.
const vecRows = await tbl
.query()
.nearestTo(vector)
.limit(CANDIDATES)
.toArray();
// Full-text (BM25) branch. Degrade gracefully to vector-only if the FTS
// index is missing or the query cannot be parsed.
let ftsRows = [];
try {
ftsRows = await tbl
.query()
.nearestToText(text)
.limit(CANDIDATES)
.toArray();
}
catch {
ftsRows = [];
}
return fuseRRF(vecRows, ftsRows).map((r) => ({
score: r.score.toFixed(4),
uri: r.row.uri ?? 'unknown',
title: r.row.title ?? 'unknown',
document: String(r.row.text ?? ''),
id: r.row.id ?? 'unknown',
}));
}
}