Knowledge Base
Staff-authored docs with pgvector + full-text hybrid search — chunked and embedded off the request path, fused with reciprocal rank fusion, consumed by Rover and the social agent.
The knowledge base is where a school writes down its policies, FAQs, and how-tos — and the retrieval engine that lets two AI consumers (Rover and the social agent) answer questions from it. It's the API's one pgvector feature, and its architecture separates cleanly: writes are ordinary CRUD, embedding happens in a worker, and search reads both indexes at once.
Two tables, one deliberately unmappable
knowledge_base (@TenantScoped) holds the document: title, description, a Markdown content blob, updatedBy — and isPublic, default false. That flag is the second access axis: docs are staff-only until explicitly published, and the public-facing social agent can only ever see isPublic rows.
knowledge_base_chunk is the search index — and it's declared @Entity('knowledge_base_chunk', { synchronize: false }) because its two working columns can't be expressed by TypeORM: embedding vector(1536) (pgvector, HNSW index) and content_tsv tsvector (generated column, GIN index). The schema lives in hand-written SQL migrations, migration:generate skips the table entirely, and CI runs a pgvector/pgvector:pg16 Postgres so those migrations apply (see The Database). Chunks deliberately store no tenancy or visibility of their own — both are read live via a join to the parent at search time, so publishing a doc never touches its chunk rows.
Writes enqueue, the worker embeds
CRUD is a plain staff controller (Resource.KNOWLEDGE_BASE per action, every write audited — with the content blob excluded from snapshots). The interesting part is when embedding happens:
- Create always enqueues an
embedjob on thekb-embeddingqueue. - Update enqueues only when
contentactually changed — retitling or publishing a doc costs nothing, because visibility is read live. - Delete soft-deletes the doc and hard-deletes its chunks — derived data doesn't get soft-delete ceremony.
The worker processor loads the doc scoped by the job's schoolId (no request context in a worker — the job carries the tenant), splits content into ≤2000-character chunks, prefixes every chunk with title — description so a retrieved fragment still carries its context, embeds the batch with text-embedding-3-small (1536 dims — must match the column), and replaces the index transactionally: delete-all, then insert with raw ::vector SQL.
Hybrid search
hybridSearch({ query, publicOnly?, limit? }) is the single retrieval door, and it fails closed: no active school context (and no operator bypass) → it throws rather than search across tenants. Because the chunk SQL is raw, the tenant boundary is bound explicitly — kb.school_id = $n — one of the few places @TenantScoped can't do it for you.
limitclamps to [1, 10] (default 5); each arm fetches a pool oflimit × 4candidates.- The query embeds with the same model, then both arms run in parallel: vector (
ORDER BY embedding <=> $1::vector, cosine over HNSW) and keyword (content_tsv @@ websearch_to_tsquery('english', …)ranked byts_rank_cdover GIN). - Reciprocal rank fusion merges the two rankings — each result scores
Σ 1/(60 + rank)across the lists it appears in — then the toplimitreturn as hits.
Why hybrid: vectors find "how do refunds work" when the doc says fee reversal policy; full-text wins on exact names and codes. RRF needs no score normalization between the two — only ranks — which is what makes the fusion robust.
Two consumers, two visibility levels
| Consumer | Call | Sees |
|---|---|---|
Rover staff tool (searchKnowledgeBase) | hybridSearch({ publicOnly: false }) inside runInToolScope | Everything in the school — internal + public |
| Social agent (WhatsApp/social channels via the social-connection module) | withSchool(schoolId, …) then hybridSearch({ publicOnly: true, limit: 5 }) | Published docs only — the external-safe subset |
That one boolean is the entire boundary between "what staff can ask Rover" and "what the public-facing agent will ever repeat" — treat isPublic review accordingly.