99 lines
4.1 KiB
TypeScript
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 }
|