<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Peter Wu</title>
    <description>The latest articles on Forem by Peter Wu (@peter_wu_8ad6dcfb10c6f20d).</description>
    <link>https://forem.com/peter_wu_8ad6dcfb10c6f20d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3936888%2F5be04a8d-92c3-4fc2-8ece-2236c71a217d.jpg</url>
      <title>Forem: Peter Wu</title>
      <link>https://forem.com/peter_wu_8ad6dcfb10c6f20d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/peter_wu_8ad6dcfb10c6f20d"/>
    <language>en</language>
    <item>
      <title>When Adding Documents Broke My Search System</title>
      <dc:creator>Peter Wu</dc:creator>
      <pubDate>Sun, 17 May 2026 22:20:33 +0000</pubDate>
      <link>https://forem.com/peter_wu_8ad6dcfb10c6f20d/how-i-built-a-rag-system-with-a-7-million-node-knowledge-graph-and-why-vector-search-alone-isnt-4f3e</link>
      <guid>https://forem.com/peter_wu_8ad6dcfb10c6f20d/how-i-built-a-rag-system-with-a-7-million-node-knowledge-graph-and-why-vector-search-alone-isnt-4f3e</guid>
      <description>&lt;h2&gt;
  
  
  How I learned that vector search alone isn't enough, and built a knowledge graph that finds what embeddings miss
&lt;/h2&gt;




&lt;p&gt;I uploaded a 10,000-page medical textbook to my RAG system. The upload succeeded. The chunks were clean. The embeddings looked fine.&lt;/p&gt;

&lt;p&gt;Then I searched for &lt;code&gt;allow_dangerous_code=True&lt;/code&gt; — a LangChain parameter I knew was on page 114 of another document in my library.&lt;/p&gt;

&lt;p&gt;It was gone. Not just ranked lower. Gone.&lt;/p&gt;

&lt;p&gt;The new textbook's 10,000 chunks had shifted the vector index, pushing the correct page below the similarity threshold. My search system had become worse because I added more knowledge to it.&lt;/p&gt;

&lt;p&gt;That was the moment I realized vector search has a blind spot that knowledge graphs don't.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Table of Contents&lt;/strong&gt;                                                                                                                                                                                                                                                                                                                                        &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
The problem with pure vector search
&lt;/li&gt;
&lt;li&gt;Why a knowledge graph sees what vectors miss&lt;/li&gt;
&lt;li&gt;
The architecture: two-stage retrieval
&lt;/li&gt;
&lt;li&gt;
The knowledge graph at scale
&lt;/li&gt;
&lt;li&gt;
Four strategies for traversing the graph
&lt;/li&gt;
&lt;li&gt;
Making the graph explain itself
&lt;/li&gt;
&lt;li&gt;
Three-layer NER: why one model isn't enough
&lt;/li&gt;
&lt;li&gt;
Adaptive chunking with bridge generation
&lt;/li&gt;
&lt;li&gt;
Vision OCR: images aren't invisible content
&lt;/li&gt;
&lt;li&gt;
Conversations become knowledge
&lt;/li&gt;
&lt;li&gt;
Production infrastructure
&lt;/li&gt;
&lt;li&gt;
What I'd tell someone building their own
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The problem with pure vector search
&lt;/h2&gt;

&lt;p&gt;Vector embeddings compress meaning into fixed-dimensional numbers. They're remarkable, but they have a fundamental limitation: precise terms get diluted in the embedding space.&lt;/p&gt;

&lt;p&gt;"allow_dangerous_code=True" isn't semantically rich. It's a code parameter. An embedding model can't distinguish it from general security content — it just sees another configuration string floating in a sea of medical terminology. When your index doubles in size overnight, strings like this drop below the similarity floor and never surface.&lt;/p&gt;

&lt;p&gt;The worst part? You don't notice it happening. There's no error. No crash. Your system just silently returns worse results, and unless you happen to search for something you know should be there, you'll never catch it.&lt;/p&gt;

&lt;p&gt;I caught it because I got lucky. But it made me ask: what else had gone missing?&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a knowledge graph sees what vectors miss
&lt;/h2&gt;

&lt;p&gt;Here's the key difference. A vector index stores "this chunk is similar to this query." A knowledge graph stores "this concept came from this chunk." Those are fundamentally different things.&lt;/p&gt;

&lt;p&gt;In my knowledge graph, &lt;code&gt;allow_dangerous_code=True&lt;/code&gt; exists as a concept node with a direct &lt;code&gt;EXTRACTED_FROM&lt;/code&gt; edge to chunk #4521 on page 114. That edge is structural — it doesn't drift when you add more content. It doesn't depend on similarity scores or embedding dimensions. The concept either came from that chunk, or it didn't.&lt;/p&gt;

&lt;p&gt;No amount of index drift can break a pointer.&lt;/p&gt;

&lt;p&gt;The fix wasn't to bolt keyword search onto the vector pipeline as a band-aid. It was to ensure the knowledge graph retrieval path completed within its timeout budget by running concept traversals concurrently instead of sequentially. At 7.1 million nodes, the difference between sequential and concurrent traversal is the difference between a timeout and a correct answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: two-stage retrieval
&lt;/h2&gt;

&lt;p&gt;The system I built — the Librarian — uses a two-stage pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1: Knowledge graph retrieval.&lt;/strong&gt; The query gets decomposed into concepts, matched against a Neo4j fulltext index, and chunks are retrieved via those &lt;code&gt;EXTRACTED_FROM&lt;/code&gt; pointers. This stage also traverses relationships to find chunks connected through clinical or conceptual edges — content that mentions related ideas but never uses the exact query terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 2: Semantic re-ranking.&lt;/strong&gt; Chunk IDs from Stage 1 get resolved against the vector store and re-ranked by semantic similarity. This gives you the precision of graph retrieval with the relevance ordering of vector search.&lt;/p&gt;

&lt;p&gt;The pipeline, simplified:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjr2yhnhecbm6omo6v7tb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjr2yhnhecbm6omo6v7tb.webp" alt=" " width="800" height="934"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Concurrent execution is critical here. When your graph has 7.1 million nodes and a query matches 15 concepts, running those traversals sequentially will timeout. Running them concurrently keeps total latency bounded by the slowest single query rather than the sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Each concept pair gets its own traversal, all run concurrently
&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;run_traversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout_budget&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;concept_pairs&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Timeout spent = max(single_query), not sum(all_queries)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The knowledge graph at scale
&lt;/h2&gt;

&lt;p&gt;The graph currently holds 7.1 million nodes and 35.3 million relationships. About 756,000 of those nodes are concepts extracted from documents. Another 183,000 are chunk nodes. And 11.9 million &lt;code&gt;EXTRACTED_FROM&lt;/code&gt; edges connect each concept back to the specific chunks it came from.&lt;/p&gt;

&lt;p&gt;But the graph isn't just extracted content. It's enriched with three external knowledge bases:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wikidata&lt;/strong&gt; (392,000 entities) provides entity disambiguation. When the system extracts "Chelsea" from a document, it needs to know whether that's a football club, a neighborhood in Manhattan, or a person — each connecting to different chunks, different contexts, different answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ConceptNet&lt;/strong&gt; (1.8 million concepts, 3.4 million relationships) connects concepts across documents through semantic relationships. "Neural network" in a medical paper and "deep learning" in a computer science textbook get connected through shared semantic meaning even though they appear in entirely separate documents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UMLS&lt;/strong&gt; — the Unified Medical Language System — is the heavyweight. It adds 1.6 million clinical concepts, 2.4 million synonyms, and 13.9 million clinical relationships. This is what makes the system capable of clinical reasoning rather than just keyword matching.&lt;/p&gt;




&lt;h2&gt;
  
  
  Four strategies for traversing the graph
&lt;/h2&gt;

&lt;p&gt;For a given query, each concept pair runs through four traversal strategies concurrently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. UMLS 1-hop.&lt;/strong&gt; Direct clinical relationships. The system maps document concepts to UMLS concepts via &lt;code&gt;SAME_AS&lt;/code&gt; edges, then walks one step along &lt;code&gt;UMLS_REL&lt;/code&gt; edges. "Polyuria" → "has manifestation" → "Diabetes Mellitus."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. UMLS 2-hop.&lt;/strong&gt; Two-step clinical paths through an intermediate concept. "metformin" → "may treat" → "Type 2 Diabetes" → "isa" → "Diabetes Mellitus." This finds reasoning chains that no single document explicitly states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Direct inter-concept edges.&lt;/strong&gt; Named relationships between concepts extracted from documents. When a sentence like "Hepatitis B causes liver failure" passes through the extraction pipeline, it produces a &lt;code&gt;CAUSES&lt;/code&gt; edge from the Hepatitis B node to the Liver Failure node. The relationship exists as a typed edge in the graph: &lt;code&gt;(Hepatitis_B)-[:CAUSES]-&amp;gt;(Liver_Failure)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Shared-chunk co-occurrence.&lt;/strong&gt; Two query concepts that both link to the same chunk via &lt;code&gt;EXTRACTED_FROM&lt;/code&gt; edges, with no direct relationship edge between them. Concepts are connected through the chunk, not through each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;c1:&lt;/span&gt;&lt;span class="n"&gt;Concept&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$concept_1&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXTRACTED_FROM&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;chunk:&lt;/span&gt;&lt;span class="n"&gt;Chunk&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:EXTRACTED_FROM&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;c2:&lt;/span&gt;&lt;span class="n"&gt;Concept&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$concept_2&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;chunk.id&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk.text&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk.document_title&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strategy 3 fires when the extraction pipeline explicitly created a relationship. Strategy 4 fires when two concepts independently point to the same chunk but no relationship edge exists between them — the author discussed them together without ever writing a causal statement.&lt;/p&gt;

&lt;p&gt;This distinction matters enormously for clinical pattern recognition. Symptom clusters that suggest a diagnosis often aren't stated as explicit relationships in the text. The author doesn't write "polyuria causes diabetes mellitus." They describe the patient: excessive urination, unexplained thirst, elevated blood glucose. Strategy 4 captures the pattern regardless.&lt;/p&gt;

&lt;p&gt;Only 35 clinically meaningful UMLS relationship types are used out of 13.9 million edges. The rest — qualifier edges, subheading metadata — get filtered out. Without this whitelist, the 2-hop traversal drowns in noise.&lt;/p&gt;

&lt;p&gt;A single pre-check determines whether any matched concept has a UMLS bridge at all. For non-medical queries, both UMLS strategies skip entirely, preserving the timeout budget for the document-edge and co-occurrence strategies. The system adapts its budget to the query, not the other way around.&lt;/p&gt;




&lt;h2&gt;
  
  
  Making the graph explain itself
&lt;/h2&gt;

&lt;p&gt;Raw graph paths mean nothing to an LLM. When the traverser finds clinical reasoning paths for a query like &lt;em&gt;"Patient presents with polyuria, polydipsia, unexplained weight loss, and fasting blood glucose of 280 mg/dL. What is the diagnosis and first-line treatment?"&lt;/em&gt;, it produces machine-readable annotations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"metformin --[may_treat]--&amp;gt; Type 2 Diabetes --[isa]--&amp;gt; Diabetes Mellitus"
"Polyuria --[has_manifestation]--&amp;gt; Diabetes Mellitus"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are precise, but they're useless as prompt text.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;ChainSynthesizer&lt;/code&gt; converts these path annotations into a human-readable clinical reasoning gloss:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clinical reasoning paths found between query concepts:
- Diabetes Mellitus ↔ Polyuria: Polyuria presents with Diabetes Mellitus
- Diabetes Mellitus ↔ Metformin: metformin may treat Type 2 Diabetes, which is a type of Diabetes Mellitus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gloss lands in the system prompt via a &lt;code&gt;KNOWLEDGE GRAPH INSIGHTS&lt;/code&gt; slot — before the LLM reads any retrieved chunks. The model gets explicit clinical relationship context to reason from, not just documents to summarize. UMLS relationship types are mapped to readable phrases: &lt;code&gt;has_manifestation&lt;/code&gt; → "presents with," &lt;code&gt;may_treat&lt;/code&gt; → "may treat," &lt;code&gt;isa&lt;/code&gt; → "is a type of."&lt;/p&gt;

&lt;p&gt;This was the missing last mile. The traverser was collecting clinical paths. Nothing was reading them. Now those paths reach the LLM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three-layer NER: why one model isn't enough
&lt;/h2&gt;

&lt;p&gt;Generic named entity recognition models fragment medical terminology. spaCy's standard English model extracts "hepatitis," "B," and "surface" as three separate tokens instead of recognizing "hepatitis B surface antigen" as a single clinical entity.&lt;/p&gt;

&lt;p&gt;That fragmentation cascades through the entire pipeline. The knowledge graph gets three weak concepts instead of one precise one. The retrieval misses. The LLM hallucinates.&lt;/p&gt;

&lt;p&gt;The system runs three NER layers concurrently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;spaCy&lt;/strong&gt; (&lt;code&gt;en_core_web_sm&lt;/code&gt;) for general proper nouns: people, places, organizations, dates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;scispaCy&lt;/strong&gt; (&lt;code&gt;en_core_sci_sm&lt;/code&gt;) for multi-word scientific terms: "hepatitis B," "surface antigen"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom UMLS linker&lt;/strong&gt; that batch-queries candidate n-grams against the 1.6 million UMLS concepts, returning the longest matching clinical terms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Results merge with a priority hierarchy: UMLS terms override shorter scispaCy terms when they fully contain them; scispaCy terms override shorter spaCy terms. Non-overlapping terms from all layers are preserved. Each layer degrades independently — if the scientific model fails to load, the other two still produce results.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adaptive chunking with bridge generation
&lt;/h2&gt;

&lt;p&gt;Standard chunking breaks text at fixed intervals. Split a paragraph about drug interactions in half, and neither chunk makes sense alone. The problem isn't the split — it's that the split doesn't know what it's splitting.&lt;/p&gt;

&lt;p&gt;The system profiles each document's domain using Wikidata entity classification and ConceptNet relationship patterns to determine content type (medical, legal, technical, narrative). Based on that profile, it generates domain-specific configurations: where to split, how large chunks should be, and what content must stay together.&lt;/p&gt;

&lt;p&gt;After splitting, a gap analysis measures semantic distance, concept overlap, and cross-reference density between adjacent chunks. When the gap is significant, the system generates a bridge chunk using Llama 3.2 3B via Ollama — a short passage that preserves the conceptual thread between the two chunks. Each bridge is validated with cross-encoding models for semantic relevance and factual consistency. Failed bridges fall back to intelligent mechanical overlap with sentence-boundary awareness.&lt;/p&gt;

&lt;p&gt;356,000 searchable chunks, each with preserved context and domain-aware boundaries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vision OCR: images aren't invisible content
&lt;/h2&gt;

&lt;p&gt;Medical textbooks are full of tables, diagrams, clinical photographs, and charts embedded as images. Standard PDF extraction ignores them. A scanned 109MB antimicrobial therapy guide produced one chunk from text extraction alone.&lt;/p&gt;

&lt;p&gt;Every embedded image runs through Ollama vision models with content-aware routing: &lt;code&gt;minicpm-v:8b&lt;/code&gt; for structured content like tables and forms, &lt;code&gt;llama3.2-vision:11b&lt;/code&gt; for narrative content like diagrams and anatomical illustrations. The visual interpretation — description plus OCR of any text within the image — is combined with the page's native text into a unified stream before chunking.&lt;/p&gt;

&lt;p&gt;Nothing stays invisible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conversations become knowledge
&lt;/h2&gt;

&lt;p&gt;Every Q&amp;amp;A thread gets chunked, embedded, and merged into the knowledge graph using the same pipeline as documents. Concepts extracted from conversations get the same &lt;code&gt;EXTRACTED_FROM&lt;/code&gt; edges pointing to the conversation chunks they came from.&lt;/p&gt;

&lt;p&gt;A question you asked last week about drug interactions becomes retrievable context for a related question today. The system treats all knowledge sources — textbooks, clinical guidelines, and conversations — with equal priority during search. Over time, it builds a memory of what your team has explored.&lt;/p&gt;




&lt;h2&gt;
  
  
  Production infrastructure
&lt;/h2&gt;

&lt;p&gt;The stack runs on Docker locally with seven services: Neo4j, Milvus, PostgreSQL, Redis, Celery workers, a dedicated ML model server, and the FastAPI application.&lt;/p&gt;

&lt;p&gt;The ML model server lives in its own container so code changes don't require reloading 4GB of models. The app container starts in 5 seconds while embedding models, spaCy pipelines, and cross-encoders load asynchronously in the background.&lt;/p&gt;

&lt;p&gt;Document processing is distributed across Celery workers with parallel bridge generation, knowledge graph extraction, and vector storage. A quality gate validates each stage — tracking LLM failure rates, NER failure rates, and bridge generation success rates — before marking a document complete.&lt;/p&gt;

&lt;p&gt;Responses stream in real-time via WebSocket with clickable source citations. Every claim is backed by an interactive citation showing document title, page number, relevance score, and an excerpt from the source chunk. When library results are thin, the system supplements with web search via SearXNG and clearly labels the source type.&lt;/p&gt;

&lt;p&gt;Relevance detection uses two signals: score distribution analysis identifies when results cluster at the semantic floor (everything scores similarly because nothing is truly relevant), and concept specificity analysis distinguishes domain-specific terms from generic words to assess whether the knowledge graph found meaningful matches. Confidence scores adjust downward for uncertain results so the LLM doesn't overstate its certainty.&lt;/p&gt;

&lt;p&gt;Deployment to AWS uses Terraform with Neptune replacing Neo4j as the graph database, OpenSearch for vectors, ECS Fargate for containers, and CloudWatch for monitoring. The architecture is the same regardless of where it runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell someone building their own
&lt;/h2&gt;

&lt;p&gt;After 80+ specs and everything that went wrong along the way, here's what I'd prioritize if I were starting over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Vector search is necessary but not sufficient.&lt;/strong&gt; Embeddings find similar things. Knowledge graphs find connected things. You need both. The graph provides a stable retrieval path that doesn't drift when your document library grows. Build the graph alongside the vector index from day one — retrofitting it later is much harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Domain-specific NER is the difference between finding the answer and missing it entirely.&lt;/strong&gt; "Hepatitis B surface antigen" as one entity versus three fragments determines whether your retrieval pipeline works. Three concurrent NER layers with a merge hierarchy sounds like overengineering until you watch a medical query fail because your NER model doesn't know what "surface antigen" means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Concurrent execution isn't optional at scale.&lt;/strong&gt; When your knowledge graph has 7 million nodes and a query matches 15 concepts, sequential Neo4j traversals will timeout. Run them concurrently. An adaptive timeout budget — where each strategy gets allocated time from the remaining budget rather than a fixed equal slice — means the system degrades gracefully instead of hanging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Every document upload changes the retrieval landscape.&lt;/strong&gt; Adding 10,000 chunks from a new textbook shifts your vector index and can push previously retrievable content below the similarity threshold. The knowledge graph is immune to this drift. That's the argument for structural retrieval, not just the argument for having both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Spec-driven development works.&lt;/strong&gt; Each feature started as a requirements document, progressed through design with formal correctness properties, and was implemented against a task list with property-based tests. This methodology caught bugs that unit tests missed and made the system's behavior verifiable across all valid inputs.&lt;/p&gt;




&lt;p&gt;The Librarian is open source at &lt;a href="https://github.com/jeujai/Librarian" rel="noopener noreferrer"&gt;github.com/jeujai/Librarian&lt;/a&gt;. If you're building RAG systems, working in medical informatics, or pushing knowledge graphs beyond demos — I'd welcome the conversation.&lt;/p&gt;

</description>
      <category>graphrag</category>
      <category>knowledgegraph</category>
      <category>neo4j</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
