Files
template-app/modules/knowledge/adapters/vectors.ts
T

99 lines
4.1 KiB
TypeScript

// The local vector search service: the knowledge module's own backend. Chunks are embedded
// through the AI gateway's embeddings endpoint and stored in the app's vector adapter (Qdrant when
// a Qdrant service is linked, pgvector otherwise); a query is embedded the same way and searched
// with the collection as a metadata filter, so the tenant wall is the adapter's job, never the
// caller's. Snippets and chunk positions ride in the metadata and are what a citation shows.
import { createHash } from "node:crypto"
import { internal } from "@conusai-kit/core"
import type { VectorStore } from "@conusai-kit/data"
import { chunkText } from "../core/chunk.js"
import type { KnowledgeEmbeddings, KnowledgeHit, KnowledgeScope, KnowledgeSearchService } from "../core/ports.js"
const SUBJECT = "knowledge_document"
const SNIPPET = 300
export interface VectorKnowledgeSearchOptions {
embeddings: KnowledgeEmbeddings
store: VectorStore
}
export function vectorKnowledgeSearch(options: VectorKnowledgeSearchOptions): KnowledgeSearchService {
const { embeddings, store } = options
return {
async readiness() {
// The local backend is ready when it can answer a search; the embedding model is the one
// external piece, and an embeddings call is too expensive for a health probe — so readiness
// here is the store's own, and a misconfigured embedding model fails the first real call.
return { state: "ready", reasons: [] }
},
async index(scope, input) {
const text = new TextDecoder("utf-8", { fatal: false }).decode(input.bytes)
const chunks = chunkText(text)
const vectors = await embeddings.embed(
chunks.map((chunk) => chunk.text),
{ inputType: "passage" },
)
if (vectors.length !== chunks.length) {
throw internal(`the embeddings answer held ${vectors.length} vectors for ${chunks.length} chunks`)
}
const vectorAt = (at: number): readonly number[] => {
const vector = vectors[at]
if (vector === undefined) throw internal("the embeddings answer held no vector for a chunk")
return vector
}
await store.upsert(
scope,
chunks.map((chunk, at) => ({
id: stableChunkId(input.fileId, chunk.position),
vector: vectorAt(at),
subject: { type: SUBJECT, id: input.fileId },
metadata: {
collection: input.collectionName,
filename: input.filename,
chunk: chunk.position,
snippet: chunk.text.slice(0, SNIPPET),
},
})),
)
},
async search(scope, input) {
const vectors = await embeddings.embed([input.query], { inputType: "query" })
const vector = vectors[0]
if (!vector) return []
const matches = await store.search(scope, {
vector,
limit: input.limit,
filter: { collection: input.collectionName },
})
const hits: KnowledgeHit[] = []
for (const match of matches) {
const metadata = match.metadata ?? {}
const snippet = typeof metadata.snippet === "string" ? metadata.snippet : ""
const chunk = typeof metadata.chunk === "number" ? metadata.chunk : 1
const filename = typeof metadata.filename === "string" ? metadata.filename : match.subject.id
const collectionName = typeof metadata.collection === "string" ? metadata.collection : input.collectionName
hits.push({ fileId: match.subject.id, filename, collectionName, score: match.score, chunk, snippet })
}
return hits
},
async remove(scope, input) {
await store.deleteBySubject(scope, { type: SUBJECT, id: input.fileId })
},
}
}
// A deterministic chunk id keeps a re-indexed chunk under one identity; the vector adapters
// namespace it per organization themselves.
function stableChunkId(fileId: string, position: number): string {
const hex = createHash("sha256").update(`${fileId}:${position}`).digest("hex").slice(0, 32)
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
}
// Types referenced by the composition root when it binds the adapter.
export type { KnowledgeHit, KnowledgeScope }