VoidBot/scripts/migrate-local-vectors-to-qdrant.ts
2026-04-24 13:17:00 +01:00

191 lines
5.4 KiB
TypeScript

import "dotenv/config";
import { access, readdir, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { join } from "node:path";
import { loadConfig } from "@voidbot/config";
import {
createTextEmbedder,
QdrantVectorStore,
type StoredQdrantChunk,
} from "@voidbot/rag";
interface CliOptions {
wipe: boolean;
}
interface StoredVectorIndex {
chunks?: StoredQdrantChunk[];
}
async function main(): Promise<void> {
if (!process.env.DISCORD_OWNER_ID) {
process.env.DISCORD_OWNER_ID = "__qdrant_migration__";
}
const config = loadConfig();
const options = parseArgs(process.argv.slice(2));
const historyEmbedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: config.ragQueryInstruction,
});
const sourceEmbedder = createTextEmbedder({
backend: config.ragEmbeddingBackend,
hashDimensions: config.ragEmbeddingDimensions,
ollamaBaseUrl: config.ragOllamaBaseUrl,
ollamaModel: config.ragOllamaModel,
ollamaTimeoutMs: config.ragOllamaTimeoutMs,
queryInstruction: config.ragSourceQueryInstruction,
});
const historyStore = new QdrantVectorStore({
url: config.qdrant.url,
apiKey: config.qdrant.apiKey,
timeoutMs: config.qdrant.timeoutMs,
collectionName: config.qdrant.historyCollection,
corpusKind: "discord_history",
embedder: historyEmbedder,
});
const sourceStore = new QdrantVectorStore({
url: config.qdrant.url,
apiKey: config.qdrant.apiKey,
timeoutMs: config.qdrant.timeoutMs,
collectionName: config.qdrant.sourceCollection,
corpusKind: "repository_source",
embedder: sourceEmbedder,
});
if (options.wipe) {
await historyStore.clear();
await sourceStore.clear();
}
const localVectors = await loadLocalVectors(config.storageRoot, {
historyPath: config.vectorStore.path,
sourceRoot: config.sourceVectorStoreRoot,
});
await historyStore.upsertStoredChunks(localVectors.historyChunks);
await sourceStore.upsertStoredChunks(localVectors.sourceChunks);
console.log(`Qdrant URL: ${config.qdrant.url}`);
console.log(`History collection: ${config.qdrant.historyCollection}`);
console.log(`Source collection: ${config.qdrant.sourceCollection}`);
console.log(`History chunks migrated: ${localVectors.historyChunks.length}`);
console.log(`Source chunks migrated: ${localVectors.sourceChunks.length}`);
console.log(`Source shards discovered locally: ${localVectors.sourceShardFiles}`);
}
function parseArgs(args: string[]): CliOptions {
const options: CliOptions = {
wipe: false,
};
for (const argument of args) {
if (argument === "--wipe") {
options.wipe = true;
continue;
}
throw new Error(`Unknown argument: ${argument}`);
}
return options;
}
async function loadLocalVectors(
storageRoot: string,
options: {
historyPath: string;
sourceRoot: string;
},
): Promise<{
historyChunks: StoredQdrantChunk[];
sourceChunks: StoredQdrantChunk[];
sourceShardFiles: number;
}> {
const historyChunks = await loadChunksFromPath(options.historyPath);
const sourceFiles = await listJsonFiles(options.sourceRoot);
const sourceChunks: StoredQdrantChunk[] = [];
for (const filePath of sourceFiles) {
sourceChunks.push(...(await loadChunksFromPath(filePath)));
}
if (historyChunks.length > 0 || sourceChunks.length > 0) {
return {
historyChunks,
sourceChunks,
sourceShardFiles: sourceFiles.length,
};
}
const legacyPath = join(storageRoot, "vector-store.json");
const legacyChunks = await loadChunksFromPath(legacyPath);
return {
historyChunks: legacyChunks.filter((chunk) => chunk.metadata.corpusKind !== "repository_source"),
sourceChunks: legacyChunks.filter((chunk) => chunk.metadata.corpusKind === "repository_source"),
sourceShardFiles: 0,
};
}
async function loadChunksFromPath(filePath: string): Promise<StoredQdrantChunk[]> {
if (!(await pathExists(filePath))) {
return [];
}
const raw = await readFile(filePath, "utf8");
const store = JSON.parse(raw) as StoredVectorIndex;
return (store.chunks ?? []).filter(isStoredChunk);
}
async function listJsonFiles(directory: string): Promise<string[]> {
if (!(await pathExists(directory))) {
return [];
}
const entries = await readdir(directory, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
.map((entry) => join(directory, entry.name))
.sort((left, right) => left.localeCompare(right));
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await access(targetPath, constants.F_OK);
return true;
} catch {
return false;
}
}
function isStoredChunk(value: unknown): value is StoredQdrantChunk {
if (typeof value !== "object" || value === null) {
return false;
}
const chunk = value as Partial<StoredQdrantChunk>;
return (
typeof chunk.id === "string" &&
typeof chunk.sourceId === "string" &&
(chunk.sourceKind === "discord_message" || chunk.sourceKind === "source_document") &&
typeof chunk.text === "string" &&
Array.isArray(chunk.vector) &&
typeof chunk.indexedAt === "string" &&
typeof chunk.metadata === "object" &&
chunk.metadata !== null
);
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});