Skip to content

FEAT: Unified Hybrid Knowledge Base RAG - #215

Merged
mbeps merged 13 commits into
mainfrom
dev
Nov 11, 2026
Merged

mbeps merged 13 commits into
mainfrom
dev

Conversation

@mbeps

@mbeps mbeps commented Nov 10, 2026

Copy link
Copy Markdown
Owner

Goal: Upgrade the knowledge base RAG system into a Unified 3-Tier Hybrid architecture combining upload-time LLM metadata and a PostgreSQL document relationship graph with Qdrant chunk retrieval, scoped document reading, and an isolated KB reader subagent that protects the main chat context window.

Architecture: During document ingestion (ingestDocumentPipeline), extracted text and chunks are analyzed by the user's default chat model (with deterministic fallback) to generate a high-density summary, document classification, key entities, a chunk-indexed Table of Contents (TOC), and bidirectional relationship edges (kb_document_relation) against top-K candidate documents in the knowledge base. At query time, search_knowledge_base remains the single abstracted tool exposed to the main chat model; internally, it runs an isolated KB Reader Subagent (ToolLoopAgent) equipped with the Tier 1 document/graph map, explore_kb_graph, search_kb_chunks, and context-guarded read_kb_document tools, returning a synthesized answer and citations without polluting the main thread with raw document dumps.


1. High-Level Overview

1.1 High-Level Architecture

flowchart TD
    subgraph Ingestion ["1. Upload & Ingestion Pipeline (Inngest)"]
        Upload["S3 Upload + kb_document (pending)"] --> Extract["Extract Text & Recursive Chunks"]
        Extract --> Embed["Embed Chunks -> Qdrant (with chunkIndex)"]
        Extract --> MetaLLM["LLM Metadata & TOC Extractor\n(Deterministic Fallback)"]
        MetaLLM --> Prune["Top-15 Candidate Pruning\n(Entity + Keyword Overlap)"]
        Prune --> GraphLLM["LLM Bidirectional Edge Generator"]
        GraphLLM --> PG[("PostgreSQL\nkb_document + kb_document_relation")]
    end

    subgraph Runtime ["2. Query-Time Isolated Subagent Retrieval"]
        MainLLM["Main Chat Model"] -->|"Calls search_knowledge_base(query)"| KBTool["search_knowledge_base Tool"]
        KBTool --> PreFetch["Initial Hybrid Search (Vector + FTS RRF)\n+ Tier 1 Catalog & Graph Map"]
        PreFetch --> Subagent["Isolated KB Reader Subagent\n(workerResolved ?? resolved)"]
        Subagent --> T1["explore_kb_graph\n(1-3 hop undirected traversal)"]
        Subagent --> T2["search_kb_chunks\n(Qdrant RRF + docId filter)"]
        Subagent --> T3["read_kb_document\n(Qdrant ordered chunks + Context Guard)"]
        Subagent -->|"Distilled Answer + Top Citations"| MainLLM
    end
Loading

1.2 Global Constraints

  • Zero-Git Policy: Never execute git commit, git add, git push, or git stash. Only read-only Git commands (status, diff, log) are permitted.
  • Database Primary Keys: All entity primary and foreign keys in knowledgebase and kb_document use text (UUID strings), NOT PostgreSQL native uuid column types. New tables (kb_document_relation) must use text for foreign keys to maintain type compatibility.
  • Direct Schema Imports: Import Drizzle schemas directly from @/drizzle/schemas/*; never create or import from barrel files.
  • File Size & Export Discipline: Every file must stay under 300 lines and export a single primary function, component, or schema/type bundle.
  • 100% Test Coverage Gate: vitest.config.ts enforces 100% statements, branches, functions, and lines. Never use /* v8 ignore */.
  • Resilient Ingestion: LLM metadata or graph extraction failures (missing chat model, provider timeout, malformed JSON) must fall back to deterministic metadata/TOC extraction and never fail Qdrant vector indexing.

1.3 Review Focus

  1. Unconfigured Chat Model or Malformed LLM JSON on Upload: When a user has an embedding model configured but no chat model (or a local model emits markdown-fenced/invalid JSON during ingestion), extractDocumentMetadata and generateDocumentRelations must catch the error and fall back to deterministic heading/excerpt metadata and zero graph edges without failing ingestDocumentPipeline.
  2. LLM Hallucinated Document IDs or Self-Loop Edges: During relationship generation, the LLM may output non-existent target IDs, IDs belonging to another knowledge base, or self-referential edges (sourceDocId === targetDocId). Edge sanitization must filter against the candidate ID allowlist and enforce sourceDocId !== targetDocId and (sourceDocId === doc.id || targetDocId === doc.id).
  3. Small Context Window Overflow in read_kb_document: When a subagent running on an 8k/16k model attempts to read a 40-page document (docTokens > usableBudget), guardContextBudget must block the full read and return safeToReadFull: false with the document's tableOfContents and chunkCount so the subagent reads bounded [chunkStartIndex, chunkEndIndex] slices.
  4. Concurrent Document Deletion & Re-Indexing Edge Staleness: Re-ingesting an existing document must atomically delete prior kb_document_relation rows where sourceDocId = doc.id OR targetDocId = doc.id before inserting fresh edges, and abort if kbDocument was deleted concurrently during LLM generation.
  5. Backwards Compatibility with Existing search_knowledge_base Callers & UI Citations: extractCitations in the chat UI expects result.results to be an array of { content, relevanceScore, documentId, documentName, s3Key, kbId?, kbName? }. search_knowledge_base must preserve this exact results array structure alongside the new answer and relatedDocuments fields, while toModelOutput formats a compact context representation for the main model.

2. Alternatives Evaluated

Approach Description Pros Cons / Why Rejected
1. Unified Hybrid + Internal KB Reader Subagent (Selected) Keep Qdrant + RRF chunk search, add PostgreSQL metadata + relationship graph on upload, and encapsulate multi-step graph/chunk/file reading inside search_knowledge_base via an isolated subagent. Zero main-thread context bloat; works on 8k local models and 1M frontier models; zero UI tool-picker complexity; 100% backwards-compatible with citations and transform pipelines. Adds 1 short LLM metadata call + 1 candidate-pruned edge call during background upload ingestion.
2. Pure File Reading + Graph (Drop Qdrant Vectors) Remove Qdrant vector chunking; rely strictly on LLM metadata catalog, graph edges, and reading full files. Removes vector embedding step on upload. Rejected: Fails on small-context models (8k–32k) when opening large PDFs and fails on 200+ document KBs where the catalog alone exceeds context budget.
3. Expose 3 Raw KB Tools Directly to Main Orchestrator Register explore_kb_graph, search_knowledge_base, and read_kb_document directly on the main chat model and rely on delegate_task for isolation. No internal subagent wrapper inside KB tool. Rejected: Violates abstraction goal; if the user hasn't manually enabled Subagent Delegation in the toolbar, calling read_kb_document dumps tens of thousands of tokens directly into the main chat history.
4. Dedicated Graph Database (Neo4j / Memgraph) Store document nodes and relationship edges in an external Cypher graph database. Native graph query syntax. Rejected (YAGNI): Document-level graphs have $10^1\text{–}10^3$ nodes per KB. PostgreSQL indexes + Foreign Key ON DELETE CASCADE handle 1–3 hop traversals in <1ms without extra containers or split-brain delete bugs.

3. Edge Cases & Failure Modes

Edge Case / Failure Mode Handling Strategy
No Chat Model Configured During Upload extractDocumentMetadata catches ProviderNotConfiguredError from resolveDefaultChatProvider(userId) and returns deterministic TOC + excerpt summary. generateDocumentRelations catches and returns 0 edges. Vector indexing in Qdrant succeeds normally.
Local / OpenAI-Compatible Model Rejects json_schema or Emits Markdown Code Fences Metadata and relation prompts use generateText with explicit JSON instructions and a fence-stripping parser (stripJsonFences + JSON.parse + schema.safeParse) rather than brittle provider-dependent response_format schemas.
LLM Emits Out-of-Bounds TOC Chunk Indices sanitizeTocEntries clamps chunkStartIndex and chunkEndIndex to [0, chunks.length - 1] and swaps or adjusts if chunkStartIndex > chunkEndIndex.
LLM Hallucinates Foreign targetDocId or Self-Loop in Graph generateDocumentRelations validates every edge against validCandidateIds.has(peerId), enforces sourceDocId !== targetDocId, and requires one endpoint to be doc.id. Database CHECK (source_doc_id <> target_doc_id) and Foreign Keys provide a second hard barrier.
100-Page PDF Read on 8k Context Model guardKbContextBudget checks estimatedSliceTokens > usableBudget (0.75 * contextWindow - usedTokens). If exceeded, read_kb_document returns safeToRead: false, the document's tableOfContents, and instructions to request a bounded [chunkStartIndex, chunkEndIndex] range.
Document Deleted While Ingestion is Running ingestDocumentPipeline already checks if kbDocument still exists before Qdrant upsert; generateDocumentRelations catches foreign key errors or empty document checks cleanly, and ON DELETE CASCADE on kb_document_relation purges any edges if deleted mid-flight.
Re-Indexing an Existing Document generateDocumentRelations deletes all existing kb_document_relation rows where sourceDocId = doc.id OR targetDocId = doc.id before inserting newly discovered edges, preventing stale or duplicate edges.

…ion Table)

1. **`drizzle/schemas/kb-document-schema.ts`**:
   - Extended [`kbDocument`](./drizzle/schemas/kb-document-schema.ts) with four metadata columns:
     - `documentType`: `text("document_type").$type<KbDocumentType>().notNull().default("general")`
     - `summary`: `text("summary").notNull().default("")`
     - `keyEntities`: `jsonb("key_entities").$type<string[]>().notNull().default([])`
     - `tableOfContents`: `jsonb("table_of_contents").$type<KbTocEntry[]>().notNull().default([])`
   - Preserved non-null defaults to guarantee backwards compatibility with existing rows and upload actions.

2. **`drizzle/schemas/kb-document-relation-schema.ts`**:
   - Created [`kbDocumentRelation`](./drizzle/schemas/kb-document-relation-schema.ts) table tracking directed and bidirectional graph edges between knowledge base documents:
     - Foreign keys on `kbId`, `sourceDocId`, and `targetDocId` referencing [`knowledgebase`](./drizzle/schemas/knowledgebase-schema.ts) and [`kbDocument`](./drizzle/schemas/kb-document-schema.ts) with `onDelete: "cascade"`.
     - Constraints: unique index `kb_doc_rel_edge_idx` on `(sourceDocId, targetDocId, relationType)`, indexes on `kbId` and `targetDocId`.
     - Check constraints: `kb_doc_rel_type_check` and `kb_doc_rel_no_self_loop_check` (`sourceDocId <> targetDocId`).
     - Exported `KB_RELATION_TYPES = ["references", "supersedes", "supplements", "contradicts", "depicts"] as const` and `KbRelationType`.

3. **`drizzle/db.ts`**:
   - Registered `kbDocumentRelationSchema` into the main Drizzle ORM client schema.

4. **`types/knowledgebase/kb-document-row.ts`**:
   - Exported [`KbTocEntry`](./types/knowledgebase/kb-document-row.ts#L8-L12) and [`KbDocumentType`](./types/knowledgebase/kb-document-row.ts#L17-L23).
   - Extended [`KbDocumentRow`](./types/knowledgebase/kb-document-row.ts#L33-L37) with optional `relationCount?: number`.
1. **Created [`extract-document-metadata.ts`](./lib/rag/extract-document-metadata.ts)** (279 lines, under 300-line limit):
   - Implemented `extractDocumentMetadata({ documentName, chunks, userId })` to query the user's default chat model via `resolveDefaultChatProvider` with strict JSON parsing and fence-stripping.
   - Implemented `buildDeterministicToc(chunks)` extracting markdown headings (`^#{1,4}\s+(.+)`) and numbered headings (`^\d+(\.\d+)*\.?\s+[A-Z].+`), with fallback to chunk-window ranges (`Section 1 (Chunks 0–4)`).
   - Added heuristic `inferDocumentType` classifying documents into `KbDocumentType` (`technical_spec`, `manual`, `report`, `policy`, `reference`, `general`).
   - Added `sanitizeTocEntries` ensuring all indices clamp to `[0, chunks.length - 1]` with swapped inverted bounds and non-empty title defaults.
   - Built resilient fallback to deterministic TOC and excerpt summary when the chat model is not configured, quota is exceeded, or JSON parsing fails.

2. **Created [`extract-document-metadata.test.ts`](./__tests__/lib/rag/extract-document-metadata.test.ts)**:
   - 17 test cases covering all execution paths and edge cases (empty chunks, code fences, malformed JSON, out-of-bounds/inverted TOC ranges, missing model, non-Error exceptions, whitespace chunks, all 6 heuristic document categories, character budget cap).
   - Achieved **100% full test coverage** across statements, branches, functions, and lines on `lib/rag/extract-document-metadata.ts`.
1. **Edge Generation & Two-Stage Pruning** ([`lib/rag/generate-document-relations.ts`](./lib/rag/generate-document-relations.ts)):
   - **Stale Edge Cleanup**: Deletes existing edges in [`kbDocumentRelation`](./drizzle/schemas/kb-document-relation-schema.ts) touching `doc.id` (`sourceDocId === doc.id OR targetDocId === doc.id`) before insertion so re-indexing starts fresh.
   - **Early Return**: Safely returns `0` without making an LLM call when no other ready documents exist in the knowledge base.
   - **Candidate Scoring & Pruning**: Evaluates candidate documents against the target document, awarding `+3` points per matching `keyEntities` (case-insensitive) and `+1` point per overlapping non-stopword token from titles and summaries, selecting the top 15 candidates.
   - **LLM Relationship Extraction**: Invokes `generateText` via [`resolveDefaultChatProvider`](./lib/chat/resolve-default-chat-provider.ts) with zero temperature to identify directed edges (`references`, `supersedes`, `supplements`, `contradicts`, `depicts`).
   - **Sanitization & Edge Validation**: Filters out self-loops (`sourceDocId === targetDocId`), edges referencing unknown document IDs not in the candidate set, edges where neither endpoint is `doc.id`, invalid relationship types, and duplicate edges.
   - **Resilience**: Catches missing provider configurations, provider timeouts, network failures, or malformed JSON responses, logging a warning and returning `0` without blocking document ingestion.

2. **Automated Unit & Coverage Tests** ([`__tests__/lib/rag/generate-document-relations.test.ts`](./__tests__/lib/rag/generate-document-relations.test.ts)):
   - 17 test cases covering early return, candidate ranking, candidate name tie-breaking, bidirectional edge creation, edge sanitization, malformed JSON, and error fallbacks.
   - **100% test coverage** maintained across statements, branches, functions, and lines.

3. **Knowledge Base Documentation**:
   - Updated [`.agents/learnings.md`](./.agents/learnings.md) with invariants for document relationship edge generation, candidate pruning, and ingestion resilience.
   - Re-indexed knowledge graph via `graphify update .`.
…Qdrant Range Queries

1. **Qdrant Client (`lib/rag/qdrant-client.ts`)**:
   - Registered integer payload index for `chunkIndex` (`field_name: "chunkIndex", field_schema: "integer"`) in `ensureQdrantCollection`.
   - Added `getDocumentChunksByRange(documentId, kbIds, startIndex?, endIndex?)`: scrolls `kb_chunks_*` collections, filters by range, and returns chunks sorted ascending by `chunkIndex`.
   - Added optional `documentIds?: string[]` scoping filter to `searchSemanticVectors` and `searchKeywordChunks`.
   - Kept file clean and under 300 lines limit (299 lines).

2. **Hybrid Search (`lib/rag/hybrid-search.ts`)**:
   - Added optional 5th parameter `documentIds?: string[]` to `hybridSearch`.
   - Forwarded `documentIds` to `searchSemanticVectors` and `searchKeywordChunks` while preserving 4-argument call signature when omitted.

3. **Ingestion Pipeline (`lib/rag/ingest-pipeline.ts`)**:
   - Integrated `extractDocumentMetadata({ documentName: doc.name, chunks, userId })` during document ingestion.
   - Persisted `documentType`, `summary`, `keyEntities`, and `tableOfContents` on `kbDocument` upon marking document `ready`.
   - Invoked `generateDocumentRelations` after marking document `ready` to establish bidirectional graph edges.
1. **Context Window Guard (`guardKbContextBudget`)**
   - [`lib/rag/guard-kb-context-budget.ts`](./lib/rag/guard-kb-context-budget.ts): Evaluates whether reading a full document or chunk slice will stay within 75% of the model's usable context window budget (`usableBudget = Math.floor(contextWindow * 0.75) - usedTokens`).
   - If reading exceeds the budget, it returns `safeToRead: false` and provides the document's structured `tableOfContents` as an outline fallback so the subagent can inspect sections and read targeted slice indices.

2. **Graph Explorer (`exploreKbGraph`)**
   - [`lib/rag/explore-kb-graph.ts`](./lib/rag/explore-kb-graph.ts): Queries `kbDocument` and `kbDocumentRelation` within the active knowledge bases.
   - Supports 1–3 hop undirected BFS neighborhood traversals from a `startDocId`, keyword matching across title/summary/documentType/keyEntities with 1-hop neighbor expansion, or Tier 1 catalog overview (up to 30 documents with their interconnecting edges).

3. **Isolated KB Reader Subagent (`runKbSubagent`)**
   - [`lib/rag/run-kb-subagent.ts`](./lib/rag/run-kb-subagent.ts): Runs an isolated subagent via `generateText` using internal tools:
     - `explore_kb_graph`: Traversing or querying document relations.
     - `search_kb_chunks`: Scoped hybrid search by query and document IDs.
     - `read_kb_document`: Context-guarded document chunk reader leveraging `getDocumentChunksByRange`.
   - Protects the main chat history from multi-page document dumps while tracking citations and discovered relationship edges.
   - Provides fast-path return when no chat model is passed or when no matching documents exist, and gracefully catches provider failures by returning collected hybrid retrieval hits.

4. **Upgraded `registerKnowledgebaseTool` & Orchestrator Integration**
   - [`lib/chat/register-knowledgebase-tool.ts`](./lib/chat/register-knowledgebase-tool.ts): Delegates `search_knowledge_base` to `runKbSubagent`, returning normalized citation results (`content`, `relevanceScore`, `documentId`, `documentName`, `kbId`, `kbName`) and synthesized `answer`.
   - Adds `toModelOutput` returning formatted syntheses (`KNOWLEDGE BASE SYNTHESIS`, `DOCUMENT RELATIONSHIPS`, `SOURCES`) to the main model while keeping raw tool result arrays intact for UI citation extractors.
   - [`lib/inngest/chat-response/tool-set-builder.ts`](./lib/inngest/chat-response/tool-set-builder.ts): Connects `workerResolved ?? resolved` model and context window configuration into `registerKnowledgebaseTool`.
- [`actions/knowledgebases/list-documents.ts`](./actions/knowledgebases/list-documents.ts):
  - Added query to [`kbDocumentRelation`](./drizzle/schemas/kb-document-relation-schema.ts) matching `kbId`.
  - Computed bidirectional `relationCount` (inbound + outbound edges) per document.
  - Attached `relationCount` to each returned [`KbDocumentRow`](./types/knowledgebase/kb-document-row.ts).
- [`components/knowledgebase/document-list.tsx`](./components/knowledgebase/document-list.tsx):
  - Rendered inline metadata for ready documents:
    - `documentType` badge when not `"general"` (e.g. `technical spec`).
    - `relationCount` badge when `relationCount > 0` (`1 link` / `2 links`).
    - `summary` text clamped to 2 lines (`text-[11px]`).
    - `keyEntities` badges (up to 4 tags + `+N` overflow badge).
  - Maintained existing layout for pending, processing, and failed documents.
- [`__tests__/actions/knowledgebases/list-documents.test.ts`](./__tests__/actions/knowledgebases/list-documents.test.ts):
  - Unit tests verifying `NOT_FOUND` authorization check, bidirectional `relationCount` computation, and empty relation fallback.
- [`__tests__/components/knowledgebase/document-list.test.tsx`](./__tests__/components/knowledgebase/document-list.test.tsx):
  - 10 unit tests verifying empty states, badges, summaries, entity tags with overflow, pending/processing/failed states, and document deletion transitions.
- [`__tests__/actions/knowledgebases/document-actions.test.ts`](./__tests__/actions/knowledgebases/document-actions.test.ts):
  - Updated mock chaining to verify `relationCount: 0` without regressions.
@mbeps

mbeps commented Nov 11, 2026

Copy link
Copy Markdown
Owner Author

1. Does it default to subagents reading files directly, or does it still use vector semantic search?

It uses both in a hybrid multi-tier pipeline:

  1. Vector Semantic Search is Still Active & First-Line:

    • Retrieval does not start by reading raw files.
    • When search_knowledge_base is triggered, run-kb-subagent.ts immediately pre-fetches top candidate chunks using Hybrid Search (Qdrant semantic vector similarity + full-text search with Reciprocal Rank Fusion) along with the Tier 1 document graph.
    • If no LLM model is configured for the subagent, it falls back purely to this hybrid vector/keyword search result.
  2. Isolated Subagent for Reasoning & Direct File Reading:

    • The main chat orchestrator model calls search_knowledge_base.
    • Instead of returning massive raw chunks to the main chat context, an isolated KB Reader Subagent (runKbSubagent) receives the pre-fetched hybrid hits and Tier 1 document catalog.
    • Direct file reading is on-demand, not an uncontrolled default: The subagent has access to read_kb_document to read specific document slices or full files from Qdrant chunks by index range (getDocumentChunksByRange).
    • A context guard (guard-kb-context-budget.ts) actively prevents full-file dumps: if a document exceeds 75% of the model's remaining context window, the read is blocked and returns the document's structured Table of Contents (TOC), forcing targeted slice reads (chunkStartIndex to chunkEndIndex).

2. Does it use the graphs to explore related data?

Yes, extensively at both ingestion and query time:

  1. Ingestion Time (PostgreSQL Graph Construction):

    • When documents are ingested in ingest-pipeline.ts, generate-document-relations.ts evaluates candidates in the knowledge base.
    • It prunes top candidates by shared entities/keywords and generates bidirectional typed relationship edges (references, supersedes, supplements, contradicts, depicts) stored in PostgreSQL table kb_document_relation.
  2. Query Time (Graph Exploration):

    • During retrieval, explore-kb-graph.ts runs in parallel with hybrid search to load document metadata and immediate relational edges.
    • The internal subagent is equipped with the explore_kb_graph tool, allowing it to perform 1–3 hop undirected graph traversals (startDocId, depth, or query) to discover connected documents (e.g., finding what supersedes or supplements a specification).
    • Any discovered relationships are tracked in relatedDocuments and returned alongside the synthesized answer and UI citations.

3. What the Subagent Receives First (Without Reading Files)

When search_knowledge_base is called, runKbSubagent does a fast initial retrieval pass and seeds the subagent prompt with:

  • Tier 1 Document Catalog: Summaries, document types, entity tags, and chunk counts for up to 15 relevant documents.
  • Document Relationships: Known edges from the graph (e.g. doc A supersedes doc B).
  • Initial Retrieval Hits: Top 5 chunks returned by hybrid search (Qdrant semantic vector + full-text search).

4. When the Subagent Decides to Call read_kb_document

The subagent enters an autonomous tool loop (up to 4 steps) and decides to invoke read_kb_document(documentId, chunkStartIndex?, chunkEndIndex?) when:

  1. The initial 5 chunk snippets are fragmented or incomplete:
    • The question requires sequential reading (e.g. "Walk me through the full installation steps in guide X" or "What does section 3 of spec Y say?").
  2. Catalog / Graph identifies a relevant document, but chunks were missed:
    • The subagent sees a relevant file or relationship in the catalog/graph that wasn't captured in the top-5 vector hits.
  3. Synthesis requires reading surrounding context:
    • The subagent needs paragraphs or full sections before and after an isolated snippet to understand policies, contracts, or code definitions accurately.

5. How the Reading is Executed (Budget-Guarded)

When the subagent invokes read_kb_document:

  • If the document fits comfortably (estimated tokens $\le 75%$ of model context window):
    • It pulls all ordered chunks from Qdrant via getDocumentChunksByRange.
  • If the document is too large (e.g., a 50-page PDF on an 8k/16k context model):
    • The context guard (guardKbContextBudget) blocks the full read (safeToRead: false).
    • It returns the document's Table of Contents (TOC) with chunk ranges.
    • The subagent then calls read_kb_document again with targeted bounds (chunkStartIndex to chunkEndIndex) matching only the relevant section.

@mbeps
mbeps merged commit aa98592 into main Nov 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant