Files
template-app/modules/knowledge/__tests__/vector-contract.test.ts
T

89 lines
3.8 KiB
TypeScript

// The vector contract suite against the store shape this module binds in tests: a conforming
// in-memory VectorStore that keeps the tenant wall the real adapters guarantee (org filter on
// every search and delete). The suite is the kit's own two-tenant contract; running it here pins
// the module's metadata conventions (collection filters, subject deletion) to what every adapter
// must provide, so a convention drift fails in this module's unit suite.
import { newId, type TenantScope } from "@conusai-kit/core"
import type { VectorMatch, VectorQuery, VectorRecord, VectorStore, VectorSubject } from "@conusai-kit/data"
import { runVectorStoreContract } from "@conusai-kit/testing"
import { describe, expect, it } from "vitest"
/** The in-memory stand-in with the tenant wall the adapters implement. */
function fakeVectorStore(): { store: VectorStore; failures: () => unknown[] } {
const rows = new Map<string, { scope: string; record: VectorRecord }>()
const keyOf = (orgId: string, id: string) => `${orgId}:${id}`
const failures: unknown[] = []
const cosine = (a: readonly number[], b: readonly number[]) => {
let dot = 0
let na = 0
let nb = 0
for (const [index, value] of a.entries()) {
dot += value * (b[index] ?? 0)
na += value * value
nb += (b[index] ?? 0) ** 2
}
return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb)
}
return {
failures: () => failures,
store: {
async upsert(scope: TenantScope, records: readonly VectorRecord[]) {
for (const record of records) rows.set(keyOf(scope.orgId, record.id), { scope: scope.orgId, record })
},
async search(scope: TenantScope, query: VectorQuery): Promise<readonly VectorMatch[]> {
const out: VectorMatch[] = []
for (const row of rows.values()) {
if (row.scope !== scope.orgId) continue
const metadata = row.record.metadata ?? {}
const filter = query.filter ?? {}
if (!Object.entries(filter).every(([key, value]) => metadata[key] === value)) continue
out.push({
id: row.record.id,
score: cosine(query.vector, row.record.vector),
subject: row.record.subject,
metadata,
})
}
return out.sort((a, b) => b.score - a.score).slice(0, query.limit ?? 10)
},
async deleteBySubject(scope: TenantScope, subject: VectorSubject) {
for (const [key, row] of rows.entries()) {
if (
row.scope === scope.orgId &&
row.record.subject.type === subject.type &&
row.record.subject.id === subject.id
) {
rows.delete(key)
}
}
},
},
}
}
const dimensions = 4
describe("the vector store the knowledge module relies on", () => {
it("passes the kit's two-tenant vector contract", async () => {
const { store } = fakeVectorStore()
const result = await runVectorStoreContract(async () => ({ store, dimensions }))
expect(result.failures.map((failure) => `${failure.check}: ${failure.error}`)).toEqual([])
expect(result.passed.length).toBeGreaterThan(0)
})
})
// The collection-filter convention this module writes into every chunk's metadata: a search names
// the collection and the wall holds. Pinning it here keeps the adapters' payload indexes useful.
describe("the module's metadata conventions", () => {
it("stores collection, filename, chunk and snippet so a cited result can be rendered", () => {
const record: VectorRecord = {
id: newId(),
vector: [1, 0, 0, 0],
subject: { type: "knowledge_document", id: newId() },
metadata: { collection: "contracts", filename: "a.txt", chunk: 2, snippet: "passage" },
}
expect(Object.keys(record.metadata ?? {}).sort()).toEqual(["chunk", "collection", "filename", "snippet"])
})
})