Files
HiveGrid-Wireframes/hivegrid-cloud/docs/features/rag.md
T

8.4 KiB

RAG Pipeline — Embeddings, Indexer, Retrieval

The RAG pipeline turns domain content (KB-Articles today, CRM-Notes and embeddable rows next) into a tenant-scoped semantic-search store. The retrieveRelevantContext helper exists, and Chatbot V2 has an enrichWithRAG adapter, but production stream/tool wiring is still pending.

This document covers the runtime architecture, the embedding-provider matrix, chunking + cost trade-offs, tenant-isolation guarantees, and the backfill / disaster-recovery story.

Architecture

                  +-----------------+
   KB CRUD ──►    | publishOutbox   | (inside Prisma $transaction)
                  |   rag.index.*   |
                  +--------+--------+
                           │
                           ▼
                  outbox.rag-index (BullMQ)
                           │
                           ▼
                  +-----------------+
                  | rag-indexer     | (apps/worker)
                  |                 |
                  |  chunk ──────► splitForEmbedding (cl100k)
                  |  embed ──────► EmbeddingProvider (provider-agnostic)
                  |  upsert ─────► VectorStore.upsertVectors (tenant-scoped)
                  +-----------------+

   Query path:
   query string ──► retrieveRelevantContext({tenantId, query, collections})
                       │
                       ├── embedSingle(query) via the same provider
                       ├── searchForTenant(...) fan-out across collections
                       ├── rank-merge by score
                       └── minScore filter + topK cap → snippets + sources

CRM notes and embeddable rows are planned sources, but they do not emit rag.index.* events yet. The current production producer surface is KB-article create/update/delete.

Modules in play:

  • packages/ai/src/embeddings/ — provider abstraction, chunker, embedAndUpsert, retrieveRelevantContext, outbox publisher.
  • apps/worker/src/jobs/rag-indexer.ts — BullMQ consumer for outbox.rag-index events. Tenant scope enforced from envelope to payload to vector-store call.
  • apps/worker/src/outbox/event-router.ts — routes rag.index.* events to the outbox.rag-index queue.
  • packages/cli/src/commands/rag/ — toolctrl-cli rag reindex walks registered source tables and writes upsert events into the outbox. The current registry includes KB articles only.

Provider matrix

Provider Default model Dimensions EU region Cost (per 1M tokens) When to pick
openai text-embedding-3-small 1536 No (US) ~$0.02 Cheapest + most documented. NOT suitable for tenants with hard EU residency.
scaleway bge-multilingual-gemma2 1024 Yes (FR/NL) ~€0.08 (managed inference) Default for EU-residency. OpenAI-compatible payload, hosted in EU.
aleph-alpha luminous-base 5120 Yes (DE) varies (enterprise tiers) German-residency / sovereignty requirements. Higher dim = higher storage + retrieval cost.
anthropic — — — — Placeholder — no first-party endpoint at writing time. Adapter throws EMBEDDING_PROVIDER_NOT_SUPPORTED with a hint.

Selecting the provider is a runtime decision driven by AI_EMBEDDING_PROVIDER. See the env block in .env.example for every option.

Tenant isolation

Tenant scope is enforced at four layers, each independent of the others:

  1. Outbox publisher — publishRagUpsertEvent requires tenantId as a typed argument and stamps it into the outbox tenantId column.
  2. Worker payload parsing — parseRagIndexJob rejects events whose payload tenantId is missing or empty (MISSING_TENANT error).
  3. embedAndUpsert — writes tenantId into every vector payload and explicitly drops a caller-supplied extraPayload.tenantId.
  4. retrieveRelevantContext — tenantId is a required argument; it is forwarded to searchForTenant, which injects a must filter clause that callers cannot bypass.

Cross-tenant queries return zero hits because the vector store wrapper appends the tenantId filter regardless of caller intent.

Chunking strategy

splitForEmbedding (in packages/ai/src/embeddings/chunking.ts) is a token-aware splitter:

  • Token counting via gpt-tokenizer (cl100k BPE).
  • Sentence-boundary splits where possible; hard cut as fallback for super-long sentences.
  • Defaults: 500 tokens per chunk, 50 tokens overlap.
  • The worker indexer reads RAG_CHUNK_SIZE_TOKENS and RAG_CHUNK_OVERLAP_TOKENS and passes them into embedAndUpsert. Direct SDK/CLI callers can pass options via chunkingOptions.

Rule of thumb:

  • Knowledge-base articles: 500 / 50 is right.
  • CRM-notes (short): 300 / 30 — recall is higher because notes are rarely long enough to need chunking.
  • Long rows / FAQ: 800 / 80 — fewer chunks, better contextual retrieval at the cost of slightly less precise hits.

Cost management

Embedding cost scales linearly with token count. Defensive measures already in place:

  • Async pipeline — the CLI never embeds directly; the worker controls concurrency (RAG_INDEXER_CONCURRENCY, default 2).
  • Backfill events use dispatchKey: rag:<source>:<id>:backfill so re-running rag:reindex is idempotent against an unfinished pass.
  • Per-chunk dedupe — upsert deletes prior chunk ids first, so a shrinking article does not accumulate orphan vectors.

A dedicated cost-tracking task (Phase 05 Task #54-#56) replaces the current 2-concurrency cap with per-tenant token budgets. Until then, keep RAG_INDEXER_CONCURRENCY=2 and watch the provider dashboard during the first backfill on each new tenant.

Disaster recovery

The outbox is the durable log; the vector store is the projection. The recovery sequence is therefore:

  1. Wipe the affected collection with vectorStore.deleteCollection().
  2. Run pnpm cli rag reindex --tenant=<id|all> --source=<slug|all>.
  3. Observe the worker logs / BullMQ dashboard until outbox.rag-index drains.

For a provider switch (e.g. OpenAI → Scaleway because of an outage):

  1. Update AI_EMBEDDING_PROVIDER + matching credential env on the worker pod.
  2. Restart the worker.
  3. If dimensions differ, delete the collection (step 1 above) and re-run rag:reindex — pgvector / Qdrant raise EMBEDDING_DIMENSION_MISMATCH otherwise.

Adding a new source-type

  1. Implement a RagBackfillSource in packages/cli/src/commands/rag/sources/.
  2. Add it to the registry in reindex.ts (default: include via all).
  3. In the domain service, call publishRagUpsertEvent / publishRagDeleteEvent inside the existing Prisma $transaction callback. The KB-Articles helper in apps/web/app/modules/knowledgeBase/services/kbArticleRag.server.ts is the reference implementation.
  4. Decide on the source-type slug (kebab-case) — it becomes the prefix of every chunk id (<sourceType>:<sourceId>:<chunkIndex>) and the dispatch key.

Onboarding checklist for a new tenant

  • Confirm the embedding provider env is set on both api and worker.
  • Choose the RAG collection slug (default rag-kb-articles for the knowledge base).
  • Run pnpm cli rag reindex --tenant=<new-tenant-id> after the first KB-Article import. The worker drains in the background.
  • Verify with retrieveRelevantContext({ tenantId, query, collections }) using a known phrase from one of the articles.

Observability

Every job emits structured logs with component: "rag-indexer", action, collection, tenantId, sourceType, sourceId, chunks affected and (where the provider reports it) input token count. Source text is never logged.

Failure modes:

  • EMBEDDING_PROVIDER_RATE_LIMITED — BullMQ retries automatically.
  • EMBEDDING_DIMENSION_MISMATCH — non-retriable; surface in the dead-letter queue and run the disaster-recovery sequence above.
  • RagIndexerJobError: MISSING_TENANT — bug in the upstream publisher; events lacking tenantId are rejected at parse time.

See also

  • docs/improvement-plan/04-search-rag/embedding-pipeline.md — the specification + acceptance-criteria checklist this implementation satisfies.
  • docs/features/search.md — the Meilisearch full-text counterpart; the two pipelines share the same outbox-driven worker pattern.
  • docs/architecture/vector-store.md — pgvector vs Qdrant trade-offs.