VoidBot/packages/rag/src/file-vector-store.ts
2026-04-24 13:17:00 +01:00

359 lines
10 KiB
TypeScript

import {
type EmbeddingChunk,
type RetrievalFilters,
type RetrievalResult,
type VectorStore,
} from "@voidbot/shared";
import { SerializedFileStore } from "./file-store";
import { type TextEmbedder, HashingTextEmbedder, cosineSimilarity } from "./hash-embedder";
interface StoredVectorChunk extends EmbeddingChunk {
vector: number[];
indexedAt: string;
}
interface LegacyStoredVectorChunk extends Omit<StoredVectorChunk, "sourceId" | "sourceKind"> {
sourceId?: string;
sourceMessageId?: string;
sourceKind?: "discord_message" | "source_document";
}
interface VectorIndexStore {
version: 2;
embedderId: string;
dimensions: number;
chunks: StoredVectorChunk[];
}
interface LegacyVectorIndexStore {
version?: number;
embedderId?: string;
dimensions?: number;
chunks?: StoredVectorChunk[];
}
const STORED_VECTOR_DECIMAL_PLACES = 4;
export class FileVectorStore implements VectorStore {
private readonly store: SerializedFileStore<VectorIndexStore>;
private readonly embedder: TextEmbedder;
public constructor(filePath: string, embedder: TextEmbedder = new HashingTextEmbedder()) {
this.embedder = embedder;
this.store = new SerializedFileStore(filePath, () => this.createEmptyStore());
}
public async upsert(chunks: EmbeddingChunk[]): Promise<void> {
if (chunks.length === 0) {
return;
}
const vectors = await this.embedder.embedDocuments(chunks.map((chunk) => chunk.text));
const vectorLength = validateEmbeddingBatch(vectors, chunks.length);
await this.store.mutate((store) => {
const activeStore = this.ensureCompatibility(store, vectorLength);
const positions = new Map(activeStore.chunks.map((chunk, index) => [chunk.id, index]));
const now = new Date().toISOString();
for (const [index, chunk] of chunks.entries()) {
const storedChunk: StoredVectorChunk = {
...chunk,
vector: quantizeVector(vectors[index]),
indexedAt: now,
};
const position = positions.get(chunk.id);
if (position === undefined) {
activeStore.chunks.push(storedChunk);
positions.set(chunk.id, activeStore.chunks.length - 1);
continue;
}
activeStore.chunks[position] = storedChunk;
}
});
}
public async clear(): Promise<void> {
await this.store.overwrite(this.createEmptyStore());
}
public async deleteBySourceIds(sourceIds: string[]): Promise<void> {
if (sourceIds.length === 0) {
return;
}
const sourceIdSet = new Set(sourceIds);
await this.store.mutate((store) => {
const activeStore = this.normalizeStore(store);
const nextChunks = activeStore.chunks.filter(
(chunk) => !sourceIdSet.has(chunk.sourceId),
);
activeStore.chunks = nextChunks;
store.chunks = nextChunks;
});
}
public async deleteByFilters(filters: RetrievalFilters): Promise<void> {
await this.store.mutate((store) => {
const activeStore = this.normalizeStore(store);
const nextChunks = activeStore.chunks.filter(
(chunk) => !matchesFilters(chunk.metadata, filters),
);
activeStore.chunks = nextChunks;
store.chunks = nextChunks;
});
}
public async query(
query: string,
limit: number,
filters?: RetrievalFilters,
): Promise<RetrievalResult[]> {
if (query.trim().length === 0) {
return [];
}
const queryVector = await this.embedder.embedQuery(query);
const vectorLength = validateSingleEmbedding(queryVector);
const store = this.getQueryableStore(await this.store.snapshot(), vectorLength);
return store.chunks
.filter((chunk) => matchesFilters(chunk.metadata, filters))
.map((chunk) => ({
chunk,
score: scoreChunk(query, queryVector, chunk),
}))
.filter((candidate) => candidate.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, limit)
.map(({ chunk, score }) => ({
chunkId: chunk.id,
score,
text: chunk.text,
sourceId: chunk.sourceId,
sourceKind: chunk.sourceKind,
metadata: chunk.metadata,
}));
}
private createEmptyStore(dimensions = 0): VectorIndexStore {
return createStore(this.embedder.id, dimensions);
}
private normalizeStore(rawStore: LegacyVectorIndexStore): VectorIndexStore {
return writeStore(rawStore, normalizeLegacyStore(rawStore, this.embedder.id));
}
private ensureCompatibility(
rawStore: LegacyVectorIndexStore,
vectorLength: number,
): VectorIndexStore {
const normalized = normalizeLegacyStore(rawStore, this.embedder.id);
const shouldPreserve =
shouldPreserveCurrentStore(normalized, this.embedder.id, vectorLength) ||
shouldPreserveLegacyHashStore(rawStore, this.embedder.id, vectorLength);
if (shouldPreserve) {
normalized.embedderId = this.embedder.id;
normalized.dimensions = vectorLength;
return writeStore(rawStore, normalized);
}
return writeStore(rawStore, createStore(this.embedder.id, vectorLength));
}
private getQueryableStore(
rawStore: LegacyVectorIndexStore,
vectorLength: number,
): VectorIndexStore {
const normalized = normalizeLegacyStore(rawStore, this.embedder.id);
if (shouldPreserveCurrentStore(normalized, this.embedder.id, vectorLength)) {
return normalized;
}
if (shouldPreserveLegacyHashStore(rawStore, this.embedder.id, vectorLength)) {
normalized.embedderId = this.embedder.id;
normalized.dimensions = vectorLength;
return normalized;
}
return createStore(this.embedder.id, vectorLength);
}
}
function scoreChunk(_query: string, queryVector: number[], chunk: StoredVectorChunk): number {
const cosine = cosineSimilarity(queryVector, chunk.vector);
return cosine;
}
function matchesFilters(metadata: Record<string, string>, filters?: RetrievalFilters): boolean {
if (!filters) {
return true;
}
if (filters.guildId && metadata.guildId !== filters.guildId) {
return false;
}
if (filters.channelId && metadata.channelId !== filters.channelId) {
return false;
}
if (filters.authorId && metadata.authorId !== filters.authorId) {
return false;
}
if (filters.corpusKind && metadata.corpusKind !== filters.corpusKind) {
return false;
}
if (filters.repoName && metadata.repoName !== filters.repoName) {
return false;
}
if (filters.pathPrefix) {
const path = metadata.path ?? "";
if (!path.startsWith(filters.pathPrefix)) {
return false;
}
}
if (filters.language && metadata.language !== filters.language) {
return false;
}
if (filters.sourceId && metadata.sourceId !== filters.sourceId) {
return false;
}
return true;
}
function validateEmbeddingBatch(vectors: number[][], expectedCount: number): number {
if (vectors.length !== expectedCount) {
throw new Error(
`Embedding backend returned ${vectors.length} vectors for ${expectedCount} chunks.`,
);
}
return validateSingleEmbedding(vectors[0] ?? []);
}
function validateSingleEmbedding(vector: number[]): number {
if (vector.length === 0) {
throw new Error("Embedding backend returned an empty vector.");
}
return vector.length;
}
function inferVectorLength(chunks: StoredVectorChunk[]): number {
return chunks[0]?.vector.length ?? 0;
}
function isHashEmbedderId(value: string): boolean {
return value.startsWith("hash:");
}
function coerceChunks(value: unknown): StoredVectorChunk[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((chunk) => normalizeStoredChunk(chunk as LegacyStoredVectorChunk))
.filter((chunk): chunk is StoredVectorChunk => Boolean(chunk));
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function writeStore(target: LegacyVectorIndexStore, next: VectorIndexStore): VectorIndexStore {
target.version = next.version;
target.embedderId = next.embedderId;
target.dimensions = next.dimensions;
target.chunks = next.chunks;
return next;
}
function normalizeLegacyStore(
rawStore: LegacyVectorIndexStore,
fallbackEmbedderId: string,
): VectorIndexStore {
const chunks = coerceChunks(rawStore.chunks);
return {
version: 2,
embedderId: readString(rawStore.embedderId) ?? fallbackEmbedderId,
dimensions: readNumber(rawStore.dimensions) ?? inferVectorLength(chunks),
chunks,
};
}
function shouldPreserveLegacyHashStore(
rawStore: LegacyVectorIndexStore,
embedderId: string,
vectorLength: number,
): boolean {
return rawStore.version === 1 && isHashEmbedderId(embedderId) && rawStore.dimensions === vectorLength;
}
function shouldPreserveCurrentStore(
store: VectorIndexStore,
embedderId: string,
vectorLength: number,
): boolean {
return store.embedderId === embedderId && store.dimensions === vectorLength;
}
function createStore(embedderId: string, dimensions = 0): VectorIndexStore {
return {
version: 2,
embedderId,
dimensions,
chunks: [],
};
}
function quantizeVector(vector: number[]): number[] {
return vector.map((value) => Number(value.toFixed(STORED_VECTOR_DECIMAL_PLACES)));
}
function normalizeStoredChunk(chunk: LegacyStoredVectorChunk): StoredVectorChunk | undefined {
const sourceId =
typeof chunk.sourceId === "string" && chunk.sourceId.length > 0
? chunk.sourceId
: typeof chunk.sourceMessageId === "string" && chunk.sourceMessageId.length > 0
? chunk.sourceMessageId
: undefined;
if (!sourceId) {
return undefined;
}
return {
...chunk,
sourceId,
sourceKind: chunk.sourceKind ?? "discord_message",
metadata: {
...chunk.metadata,
sourceId,
corpusKind: chunk.metadata?.corpusKind ?? inferCorpusKind(chunk),
},
};
}
function inferCorpusKind(chunk: LegacyStoredVectorChunk): "discord_history" | "repository_source" {
return chunk.sourceKind === "source_document" ? "repository_source" : "discord_history";
}