Meeting → RAG ingestion — design spec
Status: Planned
Author: Christophe
Date: 2026-07-21
Repo: north-os (apps/worker-ingestion-knowledge, packages/knowledge, packages/embeddings, packages/db knowledge + meeting schema, packages/agent-runtime meeting tools)
Related: WS-12 of 2026-07-21-meeting-production-readiness · review docs/reviews/2026-07-21-meeting-parity-recording-rag-state.md (Part 3) · depends on WS-1 (non-stub provider) + WS-4 (durable transcribed-event).
Problem
Meetings are absent from the RAG. The lifecycle stops at a Claude summary: source.meeting.transcribed has one consumer (the summary consumer). "What happened" search is direct SQL only — search_matter_meetings filters a substring over the derived label, not the transcript (meeting-access.ts:107-118); the code says "transcript full-text search is a follow-up" (meeting-access.ts:78). Every other piece exists: a voyage-law-2 embedder, a pgvector chunk store, hybrid retrieval, and an ingestion worker. The missing piece is a single consumer that turns a transcribed meeting into embedded chunks.
Design (fits the existing ingestion architecture)
- Trigger. A second consumer of
source.meeting.transcribedinapps/worker-ingestion-knowledge, next to the document-changed consumer. Requires WS-4's at-least-once delivery — do not build durable ingestion on a fire-and-forget event. (Revised 2026-07-22 — see Revision below: no second consumer on the shared queue; a dedicated ingest queue fed by a ledger sweep.) - Types. Extend
doc_typewithmeeting_transcriptandchunk_kindwithtranscript_turn(knowledge.ts:57-79), so a meeting is a first-classknowledge_sourcesrow (matter_id,scope: "matter",source_uri= meeting id). - Chunking (speaker/timestamp-aware). Window the diarized
segments(speakerLabel,startMs,endMs,text) into speaker-labeled chunks — the summarizer'sSPEAKER [mm:ss]: textrendering (summarize.ts:64-84) is a ready template. Each chunk carriesmeeting_id,matter_id,speaker_label,start_ms/end_ms,organization_id,source. - Embed + upsert. voyage-law-2 (
embeddings/src/embed.ts) intoknowledge_chunks.embedding, reusing the ingest/commit pipeline. - Tenant/RLS.
knowledge_chunks/knowledge_sourcesareorganization_id-RLS-isolated exactly likemeeting/meeting_transcript; embeddings inherit the same wall. The audio is encrypted at rest, but the derived segment text is already stored plaintext and read by the summarizer — so meeting embeddings are no less protected than the existing summary path. - Retrieval. Surface meeting chunks through the existing hybrid (vector+lexical) retrieval, or upgrade
search_matter_meetingsto a semantic mode — replacing the label-substring filter with an ANN scan, closing the follow-up TODO.
Decisions to lock before build
- Prerequisite (hard): provider confirmed non-stub (WS-1) and durable transcribed-event (WS-4). Ingesting canned stub transcripts poisons the RAG.
- Ingest scope: matter-scoped meetings only, or also personal/unlinked? Decides
scope+ retrieval fences; touches the ethical-wall / matter-team visibility question. - Chunk granularity: per-turn vs windowed N-second/N-token spans (recall precision vs chunk count / voyage cost).
- Retention coupling: when a meeting/audio is purged (WS-5), its embeddings must be deleted in lockstep, or the RAG surfaces content whose source no longer exists.
- Posture: the standing model is live on-demand access, not mass pre-ingestion (
no-bulk-knowledge-ingestion). Meeting transcripts are small, high-signal — per-meeting ingest-on-transcribe is defensible but a deliberate departure worth naming.
Acceptance
A transcribed matter meeting produces speaker/time-scoped chunks in knowledge_chunks under the correct org; a semantic query ("what did the counterparty say about the indemnity cap?") returns the right turns with click-to-source (meeting id + timestamp); purging the meeting removes its chunks (revised — see Revision: the audio purge deliberately keeps the transcript and therefore the chunks; the hard-delete lockstep ships as a tested seam wired when deleteMeeting lands); and no stub-transcribed meeting is ever ingested.
Revision — 2026-07-22 (as implemented, NOS-201)
The implementation plan was adversarially verified against main (two review rounds plus a final ack round) and refined the design bullets above. The original text is kept for the record; this section is what shipped.
- No second consumer on the shared queue.
createSourceEventConsumerattaches a BullMQWorkerto the queue named by the event kind — BullMQ is competing-consumer, not fan-out. A second worker onsource.meeting.transcribedwould steal live deliveries,resummarizere-emits, and summary-backfill re-drives from the summary consumer (delaying summaries by up to the backfill grace). The trigger is instead a dedicated queuemeeting-transcript-ingest(bridge inpackages/ingestion-knowledge, the mail-corpus-bridge pattern) consumed only byworker-ingestion-knowledge; the transcribed event keeps exactly one consumer. - The ledger sweep IS the at-least-once mechanism (per WS-4's revision: the consumer owns its re-drive ledger). A scheduler-role sweep in
worker-ingestion-knowledgescans forready, non-stub, matter-scoped meetings with a valid transcript,transcribed_atat or after a fixed ingest epoch (envMEETING_RAG_INGEST_EPOCH, default the ship date — never an advancing wall-clock lookback), and noknowledge_sourcesrow (source_uri= meeting id,doc_type meeting_transcript, status active or quarantined, ingest-format version current). Tuple-keyset paging with a rotating cursor prevents a stuck prefix from starving newer meetings. A lost or never-emitted transcribed event is irrelevant to this path. - Bounded, durable retries. Each re-drive of a failed job first writes a
meeting_auditevent (reason "rag_ingest_redrive", version-stamped metadata); the durable audit count is the retry budget (capMEETING_RAG_MAX_REDRIVES, backoffMEETING_RAG_REDRIVE_BACKOFF_MS). Deterministic failures (invalid segments, zero chunks) and cap exhaustion write a terminalknowledge_sourcesrow withstatus 'quarantined'and no chunks — the operator signal and ledger tombstone; deleting it retries. - Identity + upgrades.
content_hash= sha256 over an ingest-format version, the meeting id, and the raw segments JSON (two identical-content meetings stay distinct sources); filename embeds the full meeting id (supersession-safe); the ingest-format version is stamped intoconnector_metadataat commit, and a version bump re-enqueues and supersedes v(N−1) sources. - Retrieval.
search_matter_meetingsgains a semantic leg (searchKnowledgeWithAuth,doc_type "meeting_transcript", matter-walled) merged with the previous label matching — either leg failing degrades gracefully; results carry meeting id +[mm:ss]from the chunk text. The retrieval input cleaner now derives its doc-type allowlist fromDOC_TYPESso contract and policy cannot drift. - Retention/deletion. The audio purge keeps transcript and chunks by design. Hard deletion lands as the exported, RLS-correct seam
deleteMeetingKnowledgeSources(in@workspace/knowledge, chunks cascade), to be wired by the futuredeleteMeetingpath. - Posture. Per-meeting ingest-on-transcribe is the named, deliberate exception to
no-bulk-knowledge-ingestion; pre-epoch history is a deferred backfill requiring a cost estimate and explicit human go (run by lowering the epoch env).