Effective Chunking and Knowledge Graphs in RAG
Effective Chunking is a common challenge in Retrieval-Augmented Generation (RAG), especially when dealing with structured or semi-structured text like tables, lists, or bullet points. If chunk boundaries split logically connected information, the retriever might miss crucial context.
Here are some effective strategies to address this:
1. Overlapping Chunks (Sliding Window)
Use overlapping windows when chunking so that context isn’t lost at the boundaries.
Chunk 1: Sentences 1–5
Chunk 2: Sentences 4–8
Chunk 3: Sentences 7–11
This way, the end of one chunk is preserved at the start of the next.
2. Structure-Aware Chunking
Before chunking, detect and preserve structures such as tables, bullet lists, or headers. For example:
- Use HTML/XML tags or Markdown markers to define units.
- If the text contains patterns (like table rows starting with a dash or number), chunk around them instead of fixed-size windows.
3. Hybrid Chunking
Mix:
- Semantic chunking (based on sentence or paragraph coherence), and
- Heuristic rules (like grouping rows of a table or keeping ingredients together).
4. Multi-Chunk Retrieval
Instead of retrieving top-k chunks independently, allow the retriever to:
- Return contiguous sequences of chunks, or
- Fetch related low-score chunks if they co-occur in embedding space.
This can be done by:
- Expanding the top-
kwith neighboring chunks (chunk + chunk_prev + chunk_next) - Using passage co-embedding models that consider context around a passage.
5. Post-Retrieval Re-ranking
Use a cross-encoder to re-rank retrieved chunks by relevance to the query, which can help surface semantically important chunks with lower embedding similarity.
6. Chunk Linking Metadata
Add metadata or linking between chunks, like:
{
"chunk_id": 42,
"content": "...",
"prev_chunk_id": 41,
"next_chunk_id": 43,
"section": "Ingredients"
}
During retrieval, you can expand to include adjacent or related chunks when certain criteria are met.
7. Semantic Merging During Preprocessing
When a table spans multiple pages or segments, merge rows/parts into a logical unit before vectorization. Even if text is broken in source, rejoin it using heuristics.
8. Fine-Tuning on Retrieval Errors
If you know that relevant info often gets split (e.g., ingredients in chunk 1, quantities in chunk 2), fine-tune the retriever on examples where low-similarity chunks are essential. This helps the retriever learn patterns of distributed relevance.
Summary Tactic:
Combine sliding windows + structure-aware chunking + neighborhood expansion + re-ranking. This multi-pronged approach mitigates edge-case failures and better captures contextually distributed information.
Exactly — data ingestion in RAG is deceptively complex. It’s not just about breaking text into chunks and vectorizing — it’s about preserving semantic coherence, structure, and retrievability in a way that downstream tasks (retrieval + generation) can make sense of.
That’s why different libraries and tools exist — each is trying to solve a slightly different slice of the problem. Here’s a quick breakdown of why ingestion is tricky and how tools try to help:
🧱 Core Challenges in Ingestion
- Chunk boundaries can break meaning (e.g., splitting a list or table).
- Document structures (headers, bullets, tables) don’t play well with naive chunking.
- Token limits vs. semantic units — balancing chunk size and completeness.
- Embedding models are context-limited — too much or too little breaks similarity scoring.
- Multimodal data (images, PDFs, scans) adds OCR and layout challenges.
🧰 Why There Are So Many Libraries
Each library typically specializes in a part of this:
| Library | Focus |
|---|---|
| LangChain | Modular RAG pipeline, supports various chunking and vector stores. |
| Haystack | Flexible pipeline + structured document handling. |
| LlamaIndex | Emphasizes structured/relational knowledge, document graphs, and smart chunking. |
| Unstructured.io | Intelligent parsing of PDFs, HTML, DOCX — especially for tables and layout-aware chunking. |
| txtai | Lightweight embedding + search with easy ingestion from semi-structured data. |
| Chroma / Weaviate / Qdrant | Focus more on the vector storage and fast retrieval, but offer ingestion helpers. |
🧠 Good Ingestion = Better Retrieval
The better your ingestion pipeline:
- The more aligned chunks are to real-world user queries,
- The higher the retrieval precision, and
- The more faithful and useful your generations become.
If you’re building your own ingestion pipeline, it’s worth:
- Using a layout-aware parser (like
unstructured,pdfplumber, orBeautifulSoupfor HTML), - Preserving source structure with metadata,
- And using adaptive or recursive chunking rather than naive slicing.
The Kernel Memory framework from Microsoft is a very solid foundation for building a C# RAG pipeline—but it doesn’t magically solve all ingestion issues by default. Here’s how it aligns with and where you might still need custom logic:
✅ What Kernel Memory Helps With
-
Automatic format detection & parsing It auto-extracts text from formats like PDF, Word, HTML, images (via OCR), JSON, Markdown, etc. (github.com) This means tables, lists, and structured content are parsed into plaintext, which is a big step forward.
-
Chunking + embedding + indexing pipeline It partitions documents into chunks, generates embeddings, stores them in vector DBs (like Qdrant or Azure Search), and builds the retrieval pipeline (github.com).
-
Custom handlers & partitioning control You can strip default handlers and insert your own logic at any stage in the ingestion pipeline—including custom chunking logic based on document structure (github.com).
-
Adjacency expansion support Explicit support is provided for “expanding chunks retrieving adjacent partitions”—great for recovering content split across chunks (github.com).
⚠️ What You Still Have to Handle
-
Preserving structured boundaries The default chunker is format-aware, but may still split tables or bullet lists arbitrarily. If your use-case depends on intact rows or logical tables, you’ll likely need to add custom partitioning handlers.
-
Chunk size and semantic alignment Chunking configurations (size, overlap, sentence boundaries) are configurable—but sensible defaults may not fit every domain (e.g. long ingredient tables vs narrative text).
-
Re-ranking & low_sim retrieval While KM returns citations and chunks and supports adjacent retrieval, to surface semantically important but low-scoring chunks (e.g. truncated tables), you may need additional cross-encoder re-ranking on top.
-
Edge-case handling Detecting misaligned chunks (missing context) often needs heuristics tied to your data—e.g., “tableRow missing header”—and a custom handler to merge spans before embedding.
🔧 Suggested Ingestion Pipeline with KM in C#
Here’s a functional-style, handler-based outline in C# (no regex), leveraging KM:
var km = new KernelMemoryBuilder()
.WithOpenAIDefaults(openAiKey)
.WithoutDefaultHandlers()
.Build<MemoryServerless>();
km.Orchestrator
.AddHandler<FormatAwareChunker>("extract", ExtractStep)
.AddHandler<StructuralChunkExpander>("expand", ExpandAdjacents)
.AddHandler<EmbeddingHandler>("embed", GenerateEmbeddings)
.AddHandler<VectorIndexHandler>("index", StoreInVectorDb);
await km.ImportDocumentAsync(docPath, tags: new() source);
static DocumentStepResult ExtractStep(DocumentStepResult input) =>
FormatAwareParser.ParseWithMetadata(input);
static DocumentStepResult ExpandAdjacents(DocumentStepResult input) =>
input.ExpandChunks(context: Consecutive); // keeps table integrity
This ensures:
- Text and structure extracted reliably.
- Chunk boundaries respect tables/lists.
- Adjacent chunks are considered during retrieval.
- You can further add a re-ranking step if embeddings miss low-sim chunks.
📌 Final Verdict
- Yes, Kernel Memory provides a robust RAG foundation: extraction, chunking, embeddings, indexing, retrieval, citations, and even adjacency expansion.
- No, it doesn’t eliminate the need for structure-aware chunking or re-ranking—you still need custom handlers or logic for domain-specific edge cases (like table row spillage).
- The good news: KM is built to be extended with exactly that kind of customization, and can be used entirely within a functional, handler‑based C# pipeline.
We’re now entering the territory of semantic relationships across documents, hierarchical knowledge, and long-term context, which are critical to high-quality RAG but not always covered in standard pipelines.
Here’s how to deal with a structured book (like a Physics textbook) in a RAG system:
📘 The Challenge
A Physics book has:
- Chapters that build on prerequisites (e.g. Newton’s laws → Force → Energy).
- Concepts that are distributed (e.g. “Momentum” appears in multiple chapters).
- Different levels of abstraction (definitions, equations, problems, conceptual summaries).
- Requires cross-referencing to understand fully.
A naive chunking + retrieval system will fail when:
- A chunk mentions “momentum” but lacks the broader explanation from earlier.
- A user asks, “How is energy conserved in collisions?” and needs both force and momentum background.
✅ Strategies to Handle It
1. Hierarchical Chunking + Metadata
Structure your ingestion to preserve the hierarchy:
Book → Chapter → Section → Paragraph → Sentence- Tag each chunk with this hierarchy in metadata:
{
"chapter": "4",
"chapter_title": "Momentum and Collisions",
"section": "4.2",
"section_title": "Conservation of Momentum",
"concepts": ["momentum", "conservation", "collision"]
}
This allows smarter filtering, grouping, and navigation during retrieval.
2. Concept-Based Indexing (Knowledge Graph or Tags)
Create a concept index:
- During preprocessing, extract key concepts and terms.
- Map each chunk to its concepts.
"momentum" → [chunk_23, chunk_45, chunk_112]
"energy" → [chunk_88, chunk_90, chunk_140]
During retrieval:
- Use both semantic similarity and concept overlap to expand the search.
- Can be enhanced by tools like
spaCy,scispaCy, or LLM-based labelers.
3. Link Chunks Across Chapters
Instead of treating chunks as isolated, build semantic links between them:
chunk_21in Chapter 3 explains force,chunk_44in Chapter 4 uses it implicitly.
Link them using:
- A vector-based similarity match across the whole book,
- Or a lightweight knowledge graph (
force→ used in →momentum).
Use these links at retrieval time to expand context.
4. Use Recursive or Adaptive Retriever
Use a recursive retrieval strategy:
- Start with top-k chunks.
- If they reference concepts not fully explained, follow links to prior explanations.
Example:
- Initial query retrieves a chunk on “collision energy.”
- System sees the word “momentum” is central → retrieves explanation of momentum from Chapter 3.
5. Context Expansion on Retrieval
When a chunk is selected:
- Include surrounding context (next/previous chunk).
- Optionally, add linked chunks from earlier chapters if metadata shows dependency.
6. Long Context LLMs or Summary Memory
Use long-context LLMs (e.g. GPT-4 Turbo, Claude) to:
- Stitch multiple chunks into a summary.
- Provide full answers pulling from related chapters.
You can also pre-build summary embeddings for whole chapters to retrieve a chapter-level overview before diving into specifics.
🛠️ With Microsoft Kernel Memory
You can implement this using:
- Hierarchical chunk metadata
- Tags like
concept:momentumandchapter:4 - A custom retriever that expands chunks based on shared tags
- External graph store or concept index if needed
🧠 Summary Strategy
| Technique | Role |
|---|---|
| Metadata & hierarchy | Enables semantic navigation across book structure |
| Concept tagging | Groups distributed knowledge |
| Linking chunks | Connects related pieces of knowledge |
| Context expansion | Avoids loss due to chunk limits |
| Recursive retrieval | Dynamically follows knowledge chains |
Great — here’s an example of how you can structure ingestion for a physics textbook using Microsoft Kernel Memory in C#, focusing on:
- Hierarchical metadata (chapter, section, etc.)
- Concept tagging
- Custom handler to process structured ingestion
- Functional-style code, no regex
📘 Example Structure
Assume the following simplified book content:
Chapter 3: Force
- Section 3.1: Newton’s Laws
"Newton’s Second Law states that Force = mass × acceleration..."
Chapter 4: Momentum
- Section 4.2: Conservation of Momentum
"The law of conservation of momentum builds upon Newton's Laws..."
🧱 Ingestion Pipeline Overview
var km = new KernelMemoryBuilder()
.WithOpenAIDefaults(openAiKey)
.WithoutDefaultHandlers()
.Build<MemoryServerless>();
var doc = new Document
{
Id = "physics-101",
Content = File.ReadAllText("PhysicsBook.txt"),
Tags = new Dictionary<string, string> { { "source", "PhysicsBook" } }
};
await km.ImportDocumentAsync(
doc,
handlerPipeline: new()
{
new ChapterBasedChunker(), // Custom chunker based on chapters
new ConceptTagger(), // Adds concepts like "force", "momentum"
new EmbeddingHandler(), // Standard embedding generation
new VectorIndexHandler() // Store in vector DB
}
);
🧩 Custom Chunker Example
public class ChapterBasedChunker : IDocumentHandler
{
public async Task<DocumentStepResult> HandleAsync(Document document, DocumentStepResult input)
{
var chunks = new List<DocumentChunk>();
var lines = input.Content.Split('\n');
DocumentChunk? currentChunk = null;
foreach (var line in lines.Select(l => l.Trim()))
{
if (line.StartsWith("Chapter "))
{
currentChunk = new DocumentChunk
{
Content = "",
Tags = new Dictionary<string, string>
{
["chapter"] = line,
["section"] = ""
}
};
chunks.Add(currentChunk);
}
else if (line.StartsWith("- Section "))
{
if (currentChunk != null)
currentChunk.Tags["section"] = line;
}
else if (!string.IsNullOrWhiteSpace(line) && currentChunk != null)
{
currentChunk.Content += line + " ";
}
}
return new DocumentStepResult
{
Chunks = chunks
};
}
}
🧠 Concept Tagger
public class ConceptTagger : IDocumentHandler
{
private static readonly Dictionary<string, string[]> conceptKeywords = new()
{
["force"] = new[] { "force", "acceleration", "mass" },
["momentum"] = new[] { "momentum", "collision", "conservation" },
["energy"] = new[] { "energy", "kinetic", "potential" }
};
public async Task<DocumentStepResult> HandleAsync(Document document, DocumentStepResult input)
{
foreach (var chunk in input.Chunks)
{
var foundConcepts = conceptKeywords
.Where(kvp => kvp.Value.Any(keyword =>
chunk.Content.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
.Select(kvp => kvp.Key);
chunk.Tags["concepts"] = string.Join(",", foundConcepts);
}
return input;
}
}
🧾 What You Get in the Vector DB
Each chunk will be stored with metadata like:
{
"chunk_id": "c3s1",
"content": "Newton’s Second Law states that Force = mass × acceleration...",
"tags": {
"chapter": "Chapter 3: Force",
"section": "Section 3.1: Newton’s Laws",
"concepts": "force"
}
}
🔍 During Retrieval
You can now:
- Search by query + metadata filter: e.g.,
"energy" AND chapter = '4' - Retrieve related chunks by shared
conceptstag - Pull additional context using
chapterorsectionlinkage
Excellent — this is where knowledge graphs (KG) come into play, and they’re extremely powerful in a RAG setup when concepts like force and energy are semantically related but distributed across a book.
Let’s go step-by-step on how to incorporate a knowledge graph into your RAG pipeline, especially using C# and Kernel Memory.
🧠 Why Use a Knowledge Graph in RAG
Scenario: A user asks:
“How does energy relate to force?”
But the direct answer may not exist in any single chunk. You want the system to understand:
Force → does work → causes → Energy TransferEnergy → conserved in → collision → related to → force
A KG enables:
- Semantic linking of chunks across chapters
- Query expansion based on related nodes
- Cross-concept reasoning in generation
🔄 Overview of KG-Augmented RAG
- Build a concept-level graph from your data.
- During ingestion, link each chunk to its concepts (already covered).
- When a query matches concept A, retrieve related concepts from the KG.
- Retrieve chunks from concept A and its neighbors.
- Generate with richer context.
🏗️ 1. Define Your Graph Structure
Use a simple Node and Edge model:
public class ConceptNode
{
public string Name { get; set; }
public List<string> RelatedConcepts { get; set; } = new();
}
Hardcoded example:
var knowledgeGraph = new Dictionary<string, ConceptNode>
{
["force"] = new ConceptNode { Name = "force", RelatedConcepts = ["energy", "mass", "acceleration"] },
["energy"] = new ConceptNode { Name = "energy", RelatedConcepts = ["work", "force", "momentum"] },
["momentum"] = new ConceptNode { Name = "momentum", RelatedConcepts = ["mass", "velocity", "energy"] },
};
🔍 2. Query-Time Concept Expansion
During retrieval:
public class GraphAwareRetriever
{
private readonly KernelMemory _km;
private readonly Dictionary<string, ConceptNode> _graph;
public GraphAwareRetriever(KernelMemory km, Dictionary<string, ConceptNode> graph)
{
_km = km;
_graph = graph;
}
public async Task<IReadOnlyList<MemoryQueryResult>> RetrieveWithGraphExpansion(string query)
{
var queryConcepts = ExtractConceptsFromQuery(query);
var expandedConcepts = ExpandWithGraph(queryConcepts);
var filters = expandedConcepts.Select(c => new MemoryFilter { Tags = new() { ["concepts"] = c } }).ToList();
var results = new List<MemoryQueryResult>();
foreach (var filter in filters)
{
var result = await _km.SearchAsync(query, filters: filter);
results.AddRange(result.Results);
}
return results;
}
private List<string> ExtractConceptsFromQuery(string query)
{
return _graph.Keys.Where(k => query.Contains(k, StringComparison.OrdinalIgnoreCase)).ToList();
}
private List<string> ExpandWithGraph(List<string> concepts)
{
var expanded = new HashSet<string>(concepts);
foreach (var concept in concepts)
{
if (_graph.TryGetValue(concept, out var node))
foreach (var related in node.RelatedConcepts)
expanded.Add(related);
}
return expanded.ToList();
}
}
🧠 Example Use
var retriever = new GraphAwareRetriever(km, knowledgeGraph);
var results = await retriever.RetrieveWithGraphExpansion("How does energy relate to force?");
This will:
- Detect the word “energy” in the query
- Use the graph to expand to related concepts like “force”, “work”, etc.
- Retrieve chunks tagged with any of those
🧠 Optional: Use Embeddings to Build the Graph Automatically
Instead of hardcoding the graph, you could:
- Embed all concepts (or chunk summaries)
- Use cosine similarity to detect semantic relationships
- Auto-generate a graph by linking closely related nodes
That can be a second pass after initial ingestion.
✅ Benefits
- Smarter, relational retrieval
- Handles distributed concepts across chapters
- Improves factual depth in answers
- Works great for science, law, medicine, enterprise manuals
To build a general-purpose RAG system that can automatically determine topics, extract relationships, and construct a knowledge graph using a language model, here’s how you can approach it in layers — fully automated, LLM-powered, and extendable in C#.
🧠 Goal
Build a system that, when given unstructured text (books, documents, web pages), can:
- Automatically detect key concepts.
- Discover relationships between them (e.g., “Force causes acceleration”).
- Build a knowledge graph (KG).
- Use this KG to improve retrieval and generation in RAG.
🧱 Architecture Overview
[Raw Document]
↓
[LLM-powered Topic & Relation Extractor]
↓
[Knowledge Graph Generator]
↓
[Chunk Ingestor + Metadata Tagger]
↓
[Retriever with KG Expansion]
↓
[LLM Answer Generator]
🧩 Step-by-Step Strategy
1. Concept + Relationship Extraction using LLM
For each section/chapter or chunk, call the LLM with a structured prompt:
Prompt:
Extract key scientific concepts and their relationships from the text below.
Return in this JSON format:
{
"concepts": ["Force", "Acceleration"],
"relations": [
{"from": "Force", "to": "Acceleration", "type": "causes"}
]
}
Text:
---
Newton's Second Law explains that Force equals mass times acceleration...
In C# (using OpenAI client):
public record KGExtractionResult(List<string> Concepts, List<Relation> Relations);
public class Relation { public string From; public string To; public string Type; }
public async Task<KGExtractionResult> ExtractKGFromChunkAsync(string text)
{
var prompt = $"Extract key concepts and relationships...\nText:\n---\n{text}";
var result = await openAiClient.GetChatCompletionsAsync(prompt);
var json = ExtractJsonFromResult(result);
return JsonSerializer.Deserialize<KGExtractionResult>(json);
}
2. Build and Store Knowledge Graph
You can store this as an in-memory graph or in a graph DB like Neo4j. For simplicity:
public class KnowledgeGraph
{
private Dictionary<string, ConceptNode> _nodes = new();
public void AddConceptsAndRelations(KGExtractionResult result)
{
foreach (var concept in result.Concepts)
_nodes.TryAdd(concept, new ConceptNode { Name = concept });
foreach (var rel in result.Relations)
{
_nodes[rel.From].RelatedConcepts.Add(rel.To);
// Optionally store rel.Type too
}
}
public List<string> ExpandConcept(string concept, int depth = 1)
{
var visited = new HashSet<string> { concept };
var queue = new Queue<string>();
queue.Enqueue(concept);
for (int d = 0; d < depth && queue.Count > 0; d++)
{
foreach (var _ in Enumerable.Range(0, queue.Count))
{
var current = queue.Dequeue();
if (!_nodes.ContainsKey(current)) continue;
foreach (var neighbor in _nodes[current].RelatedConcepts)
if (visited.Add(neighbor)) queue.Enqueue(neighbor);
}
}
return visited.ToList();
}
}
3. Tag Chunks with Concepts
During ingestion, you can now tag each chunk automatically with the concepts the LLM extracted.
chunk.Tags["concepts"] = string.Join(",", result.Concepts);
This becomes your vector search filter later.
4. Concept-Aware Retrieval
At query time:
- Extract concepts from the query using the same LLM call.
- Expand them using the KG.
- Retrieve chunks that match any of these concepts.
- Optionally boost results by concept depth or edge weight.
var queryConcepts = await ExtractKGFromChunkAsync(queryText);
var allConcepts = kg.ExpandConcept(queryConcepts.Concepts.First());
foreach (var concept in allConcepts)
{
var chunks = await km.SearchAsync(queryText, filters: new MemoryFilter {
Tags = new() { ["concepts"] = concept }
});
...
}
🧪 Optional Enhancements
| Feature | Description |
|---|---|
| Concept Embeddings | Embed concepts and do fuzzy matching |
| Relation Types | Store types (e.g. causes, depends on, similar to) |
| Confidence Scoring | Use LLM scoring or repetition to rank relation strength |
| Graph Summarization | Summarize subgraphs for long-context LLM usage |
| Graph-based Prompt Injection | Inject subgraph into the generation prompt |
🧠 Example Output
Chunk:
"Force equals mass times acceleration..."
Extracted:
{
"concepts": ["Force", "Mass", "Acceleration"],
"relations": [
{ "from": "Force", "to": "Acceleration", "type": "causes" },
{ "from": "Force", "to": "Mass", "type": "depends_on" }
]
}
Stored Graph:
Force ──► Acceleration
└──► Mass
✅ Summary
| Step | Tool | Purpose |
|---|---|---|
| 1. LLM-based Extraction | OpenAI / Azure OpenAI | Extract concepts + relationships |
| 2. KG Builder | In-memory C# object or DB | Store and expand knowledge |
| 3. Tagger | During ingestion | Add concept metadata to chunks |
| 4. Graph-aware Retrieval | Kernel Memory + Custom Filter | Expand and fetch related chunks |
✅ Here’s a minimal C# knowledge graph builder using mock LLM output.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
public class Relation
{
public string From { get; set; }
public string To { get; set; }
public string Type { get; set; }
}
public class KGExtractionResult
{
public List<string> Concepts { get; set; } = new();
public List<Relation> Relations { get; set; } = new();
public string ChunkId { get; set; } = string.Empty;
}
public class ConceptNode
{
public string Name { get; set; }
public List<string> RelatedConcepts { get; set; } = new();
public List<string> SourceChunks { get; set; } = new();
}
public class KnowledgeGraph
{
private Dictionary<string, ConceptNode> _nodes = new();
public void AddConceptsAndRelations(KGExtractionResult result)
{
foreach (var concept in result.Concepts)
{
if (!_nodes.TryGetValue(concept, out var node))
{
node = new ConceptNode { Name = concept };
_nodes[concept] = node;
}
node.SourceChunks.Add(result.ChunkId);
}
foreach (var rel in result.Relations)
{
if (_nodes.ContainsKey(rel.From))
_nodes[rel.From].RelatedConcepts.Add(rel.To);
}
}
public List<string> ExpandConcept(string concept, int depth = 1)
{
var visited = new HashSet<string> { concept };
var queue = new Queue<string>();
queue.Enqueue(concept);
for (int d = 0; d < depth && queue.Count > 0; d++)
{
int levelCount = queue.Count;
for (int i = 0; i < levelCount; i++)
{
var current = queue.Dequeue();
if (!_nodes.ContainsKey(current)) continue;
foreach (var neighbor in _nodes[current].RelatedConcepts)
if (visited.Add(neighbor)) queue.Enqueue(neighbor);
}
}
return new List<string>(visited);
}
public List<string> GetChunksForConcept(string concept)
{
return _nodes.ContainsKey(concept) ? _nodes[concept].SourceChunks : new List<string>();
}
}
public class KGExample
{
public static async Task<KGExtractionResult> MockLLMExtractionAsync(string chunkId, string text)
{
if (chunkId == "chunk_21")
{
var json = @"{
\"ChunkId\": \"chunk_21\",
\"concepts\": [\"Force\", \"Mass\", \"Acceleration\"],
\"relations\": [
{ \"from\": \"Force\", \"to\": \"Acceleration\", \"type\": \"causes\" },
{ \"from\": \"Force\", \"to\": \"Mass\", \"type\": \"depends_on\" }
]
}";
return JsonSerializer.Deserialize<KGExtractionResult>(json);
}
else if (chunkId == "chunk_44")
{
var json = @"{
\"ChunkId\": \"chunk_44\",
\"concepts\": [\"Kinetic Energy\", \"Velocity\", \"Force\"],
\"relations\": [
{ \"from\": \"Kinetic Energy\", \"to\": \"Velocity\", \"type\": \"depends_on\" },
{ \"from\": \"Force\", \"to\": \"Kinetic Energy\", \"type\": \"affects\" }
]
}";
return JsonSerializer.Deserialize<KGExtractionResult>(json);
}
return new KGExtractionResult();
}
public static async Task RunAsync()
{
var text21 = "Force equals mass times acceleration...";
var text44 = "Kinetic energy is influenced by velocity, which in turn is affected by applied force...";
var kg = new KnowledgeGraph();
var result21 = await MockLLMExtractionAsync("chunk_21", text21);
var result44 = await MockLLMExtractionAsync("chunk_44", text44);
kg.AddConceptsAndRelations(result21);
kg.AddConceptsAndRelations(result44);
var expanded = kg.ExpandConcept("Force", depth: 1);
Console.WriteLine("Expanded Concepts from 'Force':");
foreach (var c in expanded)
Console.WriteLine("- " + c);
Console.WriteLine("\nChunks related to 'Force':");
foreach (var chunk in kg.GetChunksForConcept("Force"))
Console.WriteLine("- " + chunk);
}
}
// Usage: await KGExample.RunAsync();
What it does:
- Extracts concepts and relations (mocked for now).
- Builds a graph (
KnowledgeGraph). - Expands related concepts to simulate graph-based retrieval.
✅ The code demonstrates a real-world example with two chunks:
chunk_21(from Chapter 3): introduces “Force”, “Mass”, “Acceleration”chunk_44(from Chapter 4): builds on Force implicitly to explain “Kinetic Energy”
The knowledge graph tracks:
- Concept relationships
- Which chunks each concept came from
Example Output:
Expanded Concepts from 'Force':
- Force
- Acceleration
- Mass
- Kinetic Energy
Chunks related to 'Force':
- chunk_21
- chunk_44
What we can do next are:
- Extend to multi-hop graph traversal
- Integrate vector search filtering
- Replace the mock with real LLM API calls
You’re exactly right to think beyond just vector storage — a smart RAG system benefits greatly from enriched, structured data and relationships alongside vector search. Here’s a breakdown of what you’re aiming for and what tech can support it.
🧠 Requirements for Smart RAG Storage
| Need | Purpose |
|---|---|
| Vector Search | Find semantically similar content |
| Relational Structure | Model structured metadata (chunks, docs, authors, timestamps) |
| Graph Traversal | Represent and traverse conceptual relationships |
| Hybrid Querying | Combine filters (e.g., metadata + concept graph + vectors) |
✅ PostgreSQL as a Unified Store
PostgreSQL can indeed serve as the single backend that supports all three:
| Capability | How PostgreSQL Supports It |
|---|---|
| Vectors | pgvector extension |
| Relational | Native relational schema support |
| Graph | Recursive CTEs + ltree, or embed a graph with edges/tables |
| Hybrid Search | JOINs + ANN queries + filters (concept, chapter, tags) |
You can build complex queries like:
SELECT chunk_id
FROM chunks
JOIN concepts ON chunks.id = concepts.chunk_id
WHERE concepts.name IN ('Force', 'Acceleration')
ORDER BY embedding <#> '[0.1, 0.2, ...]' LIMIT 5;
🔁 Compared to Others
| Store | Vectors | Relational | Graph | Notes |
|---|---|---|---|---|
| PostgreSQL + pgvector | ✅ | ✅ | ⚠️ (via CTEs or external) | Best balance. Extendable. Stable. |
| Qdrant | ✅ | ⚠️ (tags only) | ❌ | Optimized for pure vector use cases |
| Neo4j | ❌ | ⚠️ (limited) | ✅ | Great for graph but no native vector support |
| Weaviate | ✅ | ✅ | ⚠️ (auto linking) | Has relations, but less control and depth vs PG |
| Milvus | ✅ | ❌ | ❌ | Pure vector DB |
🧩 Recommended Approach (C# / RAG)
Use PostgreSQL with:
pgvectorfor embeddings- Relational schema for chunks, concepts, metadata
concept_relationstable for graphs (edges)- Index on both metadata fields and vector columns
🎯 Bonus: Tools for C# + PostgreSQL
- Npgsql: High-performance PostgreSQL driver for C#
- Entity Framework Core: If you prefer ORMs
- Dapper: Lightweight mapper for fast queries
Yes, you’re absolutely right — the ecosystem around PostgreSQL + vectors is growing fast, and now includes multiple production-ready pgvector-compatible drivers and libraries, including from Microsoft’s Kernel Memory, PostgreSQL Vector Extension, and more.
Here’s a breakdown of the current options relevant to your smart RAG in C#:
🧩 PostgreSQL + Vector Support Options
1. pgvector (Core Extension)
- What: Official PostgreSQL extension for vector similarity.
- Functions:
embedding <-> query,embedding <#> query,embedding <=> query - Status: Production ready, works with 3rd party drivers
- Indexing: IVFFlat, HNSW (Postgres 16+)
2. PostgreSQL Vector Extension Driver (C# via Npgsql-pgvector)
- Language: C#
- Use with:
Npgsql(PostgreSQL driver for .NET) -
Details:
- Clean integration with vector column types (
vector) - Full similarity search support
- Can be used with
DapperorEF Core
- Clean integration with vector column types (
3. Microsoft Kernel Memory – Postgres MemoryStore
- Project: Kernel Memory
- Backend: Supports PostgreSQL (with
pgvector) - Language: C#/.NET
-
Benefits:
- Semantic chunking, indexing, embeddings built-in
- RAG abstraction over vector store
- Pluggable backend, PostgreSQL is a supported store
✅ Best for you if you want a higher-level abstraction over chunking/indexing in C#
🔄 Which One to Use?
| Use Case | Recommendation |
|---|---|
| You want full control, custom schema | Npgsql + pgvector-dotnet |
| You want pre-built RAG memory layer | Microsoft Kernel Memory + PostgreSQL backend |
| You want to experiment with schema | Kernel Memory or raw pgvector to start flexible |
📦 Getting Started with pgvector in C#
Install pgvector-dotnet
dotnet add package Pgvector
Sample Code
using Npgsql;
using Pgvector;
await using var conn = new NpgsqlConnection("Host=localhost;Username=postgres;Database=rag;Password=pass");
await conn.OpenAsync();
using var cmd = new NpgsqlCommand("SELECT id FROM chunks ORDER BY embedding <-> @embedding LIMIT 5", conn);
cmd.Parameters.AddWithValue("embedding", new Vector(new float[] { 0.12f, 0.98f, ... }));
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
Console.WriteLine(reader.GetInt32(0));
You’re absolutely right—Pgvector is a real and production-ready .NET package, and there are now well-supported drivers for vector data in PostgreSQL. Here’s the lowdown:
✅ Pgvector in .NET Ecosystem
1. Pgvector NuGet Package
- This package (version 0.3.2+) supports C#, F#, VB, integrating seamlessly with Npgsql, Dapper, and EF Core (nuget.org).
Usage Example (Npgsql):
dotnet add package Pgvector
var builder = new NpgsqlDataSourceBuilder(connString);
builder.UseVector();
await using var dataSource = builder.Build();
var conn = dataSource.OpenConnection();
await using var cmd = new NpgsqlCommand("CREATE EXTENSION IF NOT EXISTS vector", conn);
await cmd.ExecuteNonQueryAsync();
conn.ReloadTypes();
await using var cmd2 = new NpgsqlCommand(
"INSERT INTO items (embedding) VALUES ($1)", conn);
cmd2.Parameters.AddWithValue(new Vector(new float[]{1f,2f,3f}));
await cmd2.ExecuteNonQueryAsync();
2. Microsoft.Extensions.VectorData
- Microsoft released
VectorDataextensions for .NET, offering a unified API to support vector stores—including pgvector—within frameworks like Semantic Kernel (c-sharpcorner.com).
3. Kernel Memory & Semantic Kernel Support
- Microsoft’s Kernel Memory and Semantic Kernel include PostgreSQL connectors that leverage pgvector for memory stores: namely
Microsoft.KernelMemory.MemoryDb.PostgresandMicrosoft.SemanticKernel.Connectors.PgVector(nuget.org).
🗃️ Summary Table
| Use Case | Package / Connector |
|---|---|
| Npgsql + Dapper + EF | Pgvector NuGet (e.g., v0.3.2) |
| Semantic Kernel | Microsoft.SemanticKernel.Connectors.PgVector |
| Kernel Memory MemoryDB | Microsoft.KernelMemory.MemoryDb.Postgres |
✅ So Yes:
- Pgvector .NET package exists and is mature.
- You can choose a lower-level driver like
Pgvector + Npgsql, or a higher-level approach using Kernel Memory or Semantic Kernel connectors for seamless RAG workflows in C#.
Would you like a sample project combining these—for instance, using Kernel Memory with pgvector backend and a concept-based schema?
That’s a solid and modern stack — here’s a clean architectural plan for your smart RAG solution using:
- 📚 Input: Physics book (structured knowledge, implicit and explicit links)
- 🧠 LLM: OpenAI APIs (GPT-4, embedding models)
- 💾 Storage: PostgreSQL with pgvector
- 🧱 Framework: .NET (C#)
- 🔁 Memory Layer: Microsoft Kernel Memory
- 💡 Enrichment: Custom concept extraction + knowledge graph construction
✅ System Architecture Overview
🔽 Ingestion Pipeline (Customizable with Kernel Memory)
| Stage | What Happens |
|---|---|
| 1. Chunking | Use Kernel Memory’s chunking or implement hierarchical chunker (chapter-aware) |
| 2. Embedding | Use OpenAI text-embedding-3-small or similar |
| 3. Concept Extraction | LLM-powered parsing → concepts, relations, types |
| 4. Store in Postgres | Store vectors, concepts, and edges using pgvector + relational schema |
💾 PostgreSQL Schema (Core Tables)
| Table | Description |
|---|---|
documents |
Book, Chapter, etc. metadata |
chunks |
Text chunks with embeddings (pgvector) |
concepts |
Concept per chunk (Force, Energy, etc.) |
concept_links |
Graph links: concept A → concept B |
📤 Query Pipeline (Retrieval & Generation)
- User query → embedding
-
Postgres hybrid query:
- Retrieve top-k semantically similar chunks
- Expand related concepts from graph (e.g.,
Force→Kinetic Energy) - Include cross-chapter context if needed
- LLM prompt: Combine results → final answer
🛠️ Implementation Stack (C#)
| Layer | Tool/Library |
|---|---|
| Data Access | Npgsql, Pgvector |
| Vector Search | pgvector (with HNSW or IVFFlat indexing) |
| Knowledge Graph | Custom C# logic or graph-style schema (edges) |
| Kernel Memory Adapter | Use Microsoft.KernelMemory.MemoryDb.Postgres or extend it |
| Embeddings / LLM | OpenAI SDK (OpenAI.Client) |
| API Layer | ASP.NET Core or Minimal API |
📦 Example Flow
Ingest a Chapter:
var memory = new KernelMemoryClient(...);
await memory.ImportDocumentAsync("chapter3.pdf", documentId: "ch3", index: "physics");
During Enrichment:
// Extract concepts using OpenAI
var result = await openAI.CallFunctionAsync("ExtractConceptsAndRelations", chunkText);
// Store vector, concepts, and relations in PostgreSQL
await postgres.SaveChunkAsync(chunkText, embedding, concepts, relations);
When Answering:
var queryEmbedding = await openAI.EmbedAsync("How does force relate to kinetic energy?");
var chunkIds = await postgres.FindSimilarChunks(queryEmbedding);
var related = await postgres.ExpandConceptGraph("Force");
var promptContext = await postgres.FetchChunkTexts(chunkIds.Concat(related));
var answer = await openAI.AskWithContextAsync(query, promptContext);
📈 Next Steps
Would you like help with:
- ✳️ Designing the exact PostgreSQL schema
- 🧩 Extending Kernel Memory to support knowledge graph and concept relations
- 🧪 Building the first RAG query endpoint in ASP.NET
Great — let’s design a PostgreSQL schema that supports:
- Chunked content storage (with vector embeddings)
- Concept extraction and relationships (graph structure)
- Support for semantic, relational, and graph queries
✅ Core Schema Overview
┌──────────────┐ ┌────────────┐ ┌─────────────┐
│ documents │──────▶│ chunks │──────▶│ embeddings │
└──────────────┘ └────────────┘ └─────────────┘
│
▼
┌────────────┐
│ concepts │
└────────────┘
│
▼
┌────────────────┐
│ concept_links │
└────────────────┘
🗃️ Table Definitions
1. documents
Stores books, chapters, or sections.
CREATE TABLE documents (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
type TEXT, -- e.g. book, chapter
parent_id UUID REFERENCES documents(id), -- hierarchical (book → chapter)
created_at TIMESTAMP DEFAULT NOW()
);
2. chunks
Stores text segments from documents.
CREATE TABLE chunks (
id UUID PRIMARY KEY,
document_id UUID REFERENCES documents(id),
chunk_index INT,
content TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
3. embeddings
pgvector-backed semantic representations.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE embeddings (
chunk_id UUID PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
embedding VECTOR(1536) -- OpenAI embedding size
);
CREATE INDEX ON embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
4. concepts
Concepts found in chunks.
CREATE TABLE concepts (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
chunk_id UUID REFERENCES chunks(id),
type TEXT, -- optional: noun, equation, term
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_concept_name ON concepts(name);
5. concept_links
Graph: connects related concepts.
CREATE TABLE concept_links (
id UUID PRIMARY KEY,
from_concept UUID REFERENCES concepts(id),
to_concept UUID REFERENCES concepts(id),
relation TEXT, -- e.g. "implies", "causes", "explains"
confidence FLOAT DEFAULT 1.0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_concept_graph ON concept_links(from_concept, to_concept);
🔍 Query Examples
Find all chunks related to a concept:
SELECT DISTINCT c.*
FROM chunks c
JOIN concepts con ON c.id = con.chunk_id
WHERE con.name = 'Force';
Expand graph to related concepts:
SELECT c2.name
FROM concept_links l
JOIN concepts c1 ON l.from_concept = c1.id
JOIN concepts c2 ON l.to_concept = c2.id
WHERE c1.name = 'Force';
Hybrid retrieval: semantically similar + concept related:
SELECT ch.*, v.embedding <-> '[...]' AS similarity
FROM chunks ch
JOIN embeddings v ON ch.id = v.chunk_id
JOIN concepts con ON ch.id = con.chunk_id
WHERE con.name IN ('Force', 'Acceleration')
ORDER BY similarity ASC
LIMIT 5;
Perfect — since we’re using Microsoft Kernel Memory as our base for ingestion and OpenAI for embedding/LLM, we’ll now extend or align our schema and repository layer to work within that framework.
🧠 Integration Plan
| Goal | Approach |
|---|---|
| Use Kernel Memory for ingestion | Leverage its chunking, embedding, and memory store interfaces |
| Store enriched data (concepts, links) | Extend the Kernel Memory PostgreSQL MemoryStore |
| Execute hybrid & graph queries | Build a custom C# repository over the extended schema |
✅ Adjusted PostgreSQL Schema (for Kernel Memory Alignment)
Kernel Memory already uses:
documents,chunks, andembeddingsconcepts under the hood- PostgreSQL memory store uses the
MemoryDb.Postgrespackage
We’ll add concept-level tracking and links to complement this.
You now have:
1. ✅ memory_chunks (Kernel Memory-native)
Keep using Kernel Memory’s own structure (you can leave this untouched).
2. ✅ memory_embeddings (from Kernel Memory)
Kernel Memory handles embeddings here; powered by pgvector.
3. ➕ km_concepts
CREATE TABLE km_concepts (
id UUID PRIMARY KEY,
chunk_id UUID REFERENCES memory_chunks(id),
name TEXT NOT NULL,
type TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_km_concept_name ON km_concepts(name);
4. ➕ km_concept_links
CREATE TABLE km_concept_links (
id UUID PRIMARY KEY,
from_concept UUID REFERENCES km_concepts(id),
to_concept UUID REFERENCES km_concepts(id),
relation TEXT,
confidence FLOAT DEFAULT 1.0,
created_at TIMESTAMP DEFAULT NOW()
);
These two additions let you model a lightweight knowledge graph on top of Kernel Memory’s ingestion layer.
🧱 C# Repository Layer (Modular)
You can now build a C# service class (using Npgsql + Dapper or EF Core) to query this hybrid store.
Sample: IConceptRepository.cs
public interface IConceptRepository
{
Task<List<Concept>> GetConceptsByChunkIdAsync(Guid chunkId);
Task<List<Concept>> GetRelatedConceptsAsync(string conceptName);
Task AddConceptAsync(Guid chunkId, string name, string type);
Task AddRelationAsync(Guid fromConcept, Guid toConcept, string relation, float confidence);
}
Sample: ConceptRepository.cs
public class ConceptRepository : IConceptRepository
{
private readonly NpgsqlDataSource _db;
public ConceptRepository(NpgsqlDataSource db)
{
_db = db;
}
public async Task<List<Concept>> GetConceptsByChunkIdAsync(Guid chunkId)
{
using var conn = await _db.OpenConnectionAsync();
var cmd = new NpgsqlCommand("SELECT * FROM km_concepts WHERE chunk_id = @id", conn);
cmd.Parameters.AddWithValue("id", chunkId);
var reader = await cmd.ExecuteReaderAsync();
var result = new List<Concept>();
while (await reader.ReadAsync())
{
result.Add(new Concept
{
Id = reader.GetGuid(0),
ChunkId = reader.GetGuid(1),
Name = reader.GetString(2),
Type = reader.IsDBNull(3) ? null : reader.GetString(3)
});
}
return result;
}
// Add related methods for GetRelatedConceptsAsync, AddConceptAsync, etc.
}
🔄 OpenAI Integration for Enrichment
At ingestion time (custom pipeline):
- Use OpenAI Function Call or prompt to extract concepts and relationships from chunk text.
- Save them using
ConceptRepository.
📌 Summary: Stack Overview
| Layer | Tool |
|---|---|
| Ingestion | Kernel Memory |
| Embeddings | OpenAI API (via Kernel Memory) |
| Storage | PostgreSQL + pgvector |
| Concept Graph | km_concepts, km_concept_links |
| Graph Queries | C# Repository over Npgsql |
| Language Model | OpenAI GPT-4 |
| Query API | ASP.NET Core or Minimal API |
Great — let’s now build your custom chunking pipeline within the Kernel Memory ecosystem, while supporting smart enrichment like concept extraction later.
✅ Goal
A chunking system that:
- Preserves document structure (book > chapter > section > paragraph)
- Produces semantically meaningful chunks (not just sliding windows)
- Attaches metadata (chapter number, topic, source)
- Is pluggable inside Kernel Memory
🧠 Kernel Memory: Custom Chunking Strategy
Kernel Memory supports pluggable chunking via the IContentChunker interface.
You can write your own implementation.
🧱 Step-by-Step Plan
1. Create Custom Chunker
File: SmartHierarchicalChunker.cs
public class SmartHierarchicalChunker : IContentChunker
{
public async IAsyncEnumerable<ContentChunk> ChunkAsync(Stream content, Document document, CancellationToken cancellationToken)
{
using var reader = new StreamReader(content);
var fullText = await reader.ReadToEndAsync();
var sections = SplitBySections(fullText); // your custom logic
int index = 0;
foreach (var section in sections)
{
var paragraphs = SplitIntoChunks(section);
foreach (var para in paragraphs)
{
yield return new ContentChunk
{
Index = index++,
Text = para,
Tags = new Dictionary<string, string>
{
["sectionTitle"] = GetSectionTitle(section),
["documentId"] = document.Id,
["source"] = document.Source,
["topic"] = InferTopic(section) // optional
}
};
}
}
}
private IEnumerable<string> SplitBySections(string text)
{
// Simple logic (can use ML later)
return text.Split(new[] { "\n\n", "###", "CHAPTER" }, StringSplitOptions.RemoveEmptyEntries);
}
private IEnumerable<string> SplitIntoChunks(string section)
{
var words = section.Split(' ');
var buffer = new List<string>();
const int maxWords = 150;
foreach (var word in words)
{
buffer.Add(word);
if (buffer.Count >= maxWords)
{
yield return string.Join(' ', buffer);
buffer.Clear();
}
}
if (buffer.Count > 0)
yield return string.Join(' ', buffer);
}
private string GetSectionTitle(string section)
{
// Extract first line or detect using heuristics
return section.Split('\n').FirstOrDefault()?.Trim() ?? "Unknown";
}
private string InferTopic(string section)
{
if (section.Contains("force", StringComparison.OrdinalIgnoreCase)) return "Force";
if (section.Contains("energy", StringComparison.OrdinalIgnoreCase)) return "Energy";
return "General";
}
}
2. Register the Chunker in Kernel Memory
In your setup (e.g., Program.cs if you’re using the default web host):
builder.Services.AddKernelMemory()
.WithCustomChunker<SmartHierarchicalChunker>()
.WithOpenAIDefaults(openAiConfig)
.WithPostgresMemoryDb(postgresConfig);
3. Ingest with Smart Chunking
await memory.ImportDocumentAsync(
filePath: "Book_Chapter3.txt",
documentId: "physics_ch3",
index: "physics",
tags: new() { ["chapter"] = "3", ["title"] = "Force and Motion" }
);
🧪 Testing Output
Each chunk in Kernel Memory will be available with:
- Text (semantic unit)
- Index
- Tags:
sectionTitle,topic,source, etc.
These can be used for later filtering, concept extraction, or graph building.
🧩 Next: Enrichment Pipeline
Now that chunking is structured, the next stage would be:
- Extract concepts from each chunk using OpenAI
- Store them in
km_conceptsandkm_concept_links
We can next proceed to concept enrichment using OpenAI
More from Context Engineering
- Effective Chunking and Knowledge Graphs in RAG (Current)
- LLMs as Equalizers: Translating Ideas Across Ecosystems
- Durable Memory for .NET AI Applications
- Transform Repositories into Queryable Intelligence