📱

Get Our Mobile App

Take your business learning on the go!

Download on the App StoreGet it on Google Play

This RAG Trick Makes Your AI Agents WAY More Accurate (n8n)

The AI Automators34:35

Transcription

The biggest reason RAG agents fail is that they can't see the big picture. They're generating responses from isolated fragments of a document, but they're completely blind to the document structure that gives those fragments meaning. Your agent might retrieve a chunk that says the policy was updated last month, but it has no idea what the policy is, what changed, or what impact it has.

Vector and hybrid search are excellent as a first pass narrowing of search results. But once you receive these candidate chunks, you need context expansion. This is the ability for the agent to intelligently retrieve sections, subsections, or related parts of a document to give the agent everything it needs to generate a comprehensive answer.

There are various different approaches that you can take such as neighbor expansion which fetches adjacent chunks. Parent expansion where you grab a full section. Agentic expansion where you can fetch multiple different document sections or even load up the entire document itself. I stretched N8N to its limits by creating a mechanism that can extract out a document's inherent structure and mapped it to the chunks that were upserted to the vector store. So, in this video, I'll dive into these different approaches and how they can be implemented in N8N to help you create more accurate RAG agents. As usual, if you find the video helpful, please give it a like below and subscribe to our channel for more deep AI and N8N content.

Let's kick off with a demo of agentic context expansion. So, we'll ask the question, "How do I install the Impava oven?" And I'm dropping in a product code. So, I've got a knowledge base of product manuals. First off, it goes to the vector store to retrieve candidate chunks. It then goes to fetch the document hierarchy that was generated when we upserted this document in the first place. And then it goes to our context expansion endpoint to fetch multiple sections of that document to generate the response. And here we go. There's our answer, which looks quite comprehensive.

And to break down exactly what happened within the vector store, we only actually returned four chunks. So I set that quite low just to prove this concept. And within these chunks, we're getting a document ID. So the agent can pick the best chunk and then it can go to Supabase to fetch the document hierarchy. As you can see, it passed in that document ID and it's getting back the entire hierarchy of the document. So it's all of the headings in the document along with the chunk indexes for each section and subsection. And then it goes to the context expansion endpoint, which is a Supabase edge function, and it passes in the range of chunk IDs that it wants to retrieve to flesh out the context. So it returned ranges 19 to 25. And if we look at the hierarchy, 19 to 25 encompasses the installation process, how to make an electrical connection. So it was able to figure out from the hierarchy what chunks it needed to actually answer this question.

So let's ask a question that requires information from different sections of the document. Let's ask, "How do I clean and dispose of the oven?" Okay, so we've got chunks, we've got the document hierarchy, and we've now expanded the context. And look at that, we've got a number of ranges. So chunk IDs are 18, 26 to 31, 33 to 36, 37 to 41. And that's essentially everything we need to answer this question. Chunk ID 18 is disposal. 33 to 36 is the cleaning and maintenance section, and 37 to 41 is disassembly as part of cleaning. So this is essentially document navigation based off a structure.

And what's great about using a document structure like this is that you can actually inject snippets at the start of each chunk. So we can say that this chunk is from the Impava 24-inch single wall oven's instruction manual. It's specifically in the disposal of the appliance section. And within the metadata, we have this cascading path. We know that this is in the unpacking category and the disposal of the appliance section. So H1 and H2. And we can even track which pages of the PDF that this chunk actually came from from a traceability perspective.

Agentic expansion is the most sophisticated approach that I'll be going through today. But there are other context expansion approaches which are a little bit easier to implement in N8N, and I'll be going through that shortly. As you can see, this approach solves a fundamental problem with RAG agents, which is that of lost context. So when an AI agent queries a vector store, it's going to retrieve chunks or fragments of documents that were imported. So here, for example, we have three pages of a document, and the vector store might return these three isolated fragments. But if you look at this section here, for example, you've no idea that these list items are part of the plank assembly section of the document. So you're losing the structural meaning of the document when you actually split and chunk it.

And this is a very real problem because this is how hallucinations happen. So I'll use an example here of a knowledge base that has insurance policies. And if the question is asked, "Is tennis elbow covered under this policy?" The agent will query the vector store which contains the policy that you see in front of you. However, as I said, it's only going to get back isolated fragments of that document. So it might get this first paragraph which talks about coverage. It'll get this paragraph which talks about tennis elbow. But as you can see, this piece of text doesn't mention that this is an exclusion. So without knowing that this is under the policy exclusion section of the document, how is the agent supposed to know that this is not actually covered? And invariably, what will happen is the agent will retrieve back five or 10 different chunks like that. And it could very easily make an assumption that tennis elbow is included because it was retrieved from the knowledge base. And that is a major faithfulness problem because it's not really that the agent is hallucinating; it's that it didn't get accurate enough information back from the vector store.

There are approaches that you can use to mitigate this problem. Contextual embeddings is one of them. This is an approach where you get an LLM to create a one-sentence snippet that goes at the top of the chunk. So, it would explain that this is in the policy exclusion section. The problem with contextual embeddings, however, is that it requires an LLM call for every single chunk. So, it's not really that scalable and is quite costly. Query expansion is another possible solution where the agent can send in different queries to come at the search from different angles, but that approach does lack reliability. So context expansion is a great solution to this problem, and it also can work at scale because you don't need an LLM call for every chunk.

In this video, I'll be going through five ways that you can expand out the context based off a candidate chunk retrieved from a vector store. I'll be going through fetching full documents, fetching neighboring chunks, fetching sections, fetching parents, and even fetching multiple sections of a document based off the document structure. So, let's kick off at full document expansion.

If you'd like to get access to our context expansion workflows along with our state-of-the-art N8N RAG system, then check out the link in the description to our community, the AI Automators, where you can join hundreds of builders all looking to create production RAG agents.

What's happening here is the user sends in the question, "Is tennis elbow covered under the policy?" which hits the AI agent, and they carry out a search of the vector store or the knowledge base. You could have tens of thousands of chunks in the knowledge base. So this search will help you narrow that down to the top 10 or top 20, which are then returned to the agent. And the AI agent can use its reasoning over the retrieved chunks to figure out that this chunk here, this golden chunk, is the one that it needs to expand out because this is the document that has the information. So from there, then it's able to send the document ID that's in the metadata of the chunk to fetch the entire file, and it brings all of that into context, at which point it's able to actually generate a comprehensive and accurate answer. Now, this is definitely the most expensive way to do this. If your document is 200 pages long, this is going to be very expensive per query. But if you only have a three-page document, you may as well load the entire thing in and give the agent the best possible chance to answer the question accurately.

And back to our example question about the insurance policy. In this case, this tennis elbow chunk was retrieved. The agent figured out, "Actually, that's the document I need," and then it just loads the entire document into context. And that way, it can figure out that's actually an exclusion and not covered by the policy.

So within N8N, then, we have a bit of a problem, which is within our metadata, we don't actually have a document ID. So how do we load up the entire document? So how you do this will depend on what vector store that you're using. Here, I'm using Supabase, which is built on Postgres, which is a relational database, and that's brilliant for this context expansion concept because you can interact with the chunks in your documents table using SQL as well as vector search. So in this ingestion pipeline, let's just execute it. We're grabbing a document from Google Drive. We're extracting out the text. We're setting it up to go to Supabase. We're just using the default data loader and the default recursive character text splitter. But if we go to the default data loader, you can see that we're setting `doc_id` as a metadata field. And that `doc_id` is mapped to what we got from the Google Drive file. So now, if I come into Supabase and if I go to the metadata field, we can now see `doc_id` is there.

The document that I imported here is an instruction manual for an Impava wall oven. So if we ask some questions of this, let's go down to our agent and you can see that it has this "fetch full document" tool, which I'll go through in a minute. But let's just ask the question, "How do I install the oven?" That goes to the vector store to retrieve candidate chunks, and off the back of that, it then triggers the "fetch full document" to get the entire document, and then it generates the response. And there we go, that's the full instructions that we need to install the oven.

So what just happened there? Let's get into it. So we hit the Supabase vector store, and the agent rewrote the query to something more keyword-rich to try to pick up the right chunks from the vector store. And on the right-hand side here, we can see that we retrieve chunks. Now, I've limited to four here just to prove this point, but you can see that we're getting back "instruction manual Impava," but crucially, we're getting back `doc_id`, and that's the unique identifier. So then we hit this "fetch full document" tool, and what this is doing is it's a simple `SELECT` statement. So it's selecting the content and the metadata from the `documents` table, which is the vector store, where the metadata `doc_id` equals, and then the AI agent is tasked to inject the correct document ID. So let's say the agent got back 20 chunks and it realized the 12th chunk was the one that contained the information. Then it can extract the `doc_id` from that 12th chunk, drop it in here, and load up the entire document where that chunk came from. So this is one of the main reasons we recommend Postgres for a RAG system because you can carry out SQL queries as well as vector searches against the documents table.

But there are other approaches here. When you're ingesting the document initially, you could also save it into a bucket somewhere and save the file path, and then this could just be loading up a file from a path. Or if you're running a different vector store like Pinecone or Quadrant, you could run Postgres side-by-side, and that way you have the best of both worlds. And the great thing about this approach is that it's returning all of the chunks of that document. So you can see 30 items have been returned. And let's just make one change to this query. So let's order this by the ID ascending. And that way, the chunks that are returned are exactly the same as they appear in the source document. So let's run that tool again. I just paste back in that document ID. And now you can see this is in the exact same order as we have here. So "instruction manual 24-inch single wall oven." That's what we have there. And then the IDs are in ascending order. So this is now effectively the same as just loading up the document from a file system.

And if you were worried about the cost and latency of loading up a full massive document into the context window of an LLM, you could save the total character count of the document in the metadata as well. And then you could put it in the instructions of the agent to say, "If the document is under a certain number of characters, only then trigger this query." It's also worth noting that I'm using an AI agent here, but this could be done in a more deterministic LLM chain as opposed to an agent. So, for example, you could have the query coming in, which could then hit an LLM, and this LLM could essentially rewrite the query that goes to the vector store. So, this would be "get ranked documents from the vector store," and that should retrieve the 10 to 20 chunks, and you could use reranking there as well. And then you would need another LLM chain to basically decide what's the best chunk to expand. So that would be there, and then you could have just an `IF` node. So if the selected chunk's document size is less than whatever the target is, then you could trigger the Postgres query to fetch all of the chunks of the document, or it could be "fetch the file from a disk," and then finally another LLM chain to finish it off to formulate the response. So it can be done in a more deterministic fashion, and even though there are way more nodes there, that would be lightning fast if you were using very small models, whereas here with the reasoning agent, that could be much longer and much more expensive.

Onto option two, which is neighbor expansion. So in this case, we get our user's question, we get our candidate chunks from the vector store as usual. The agent figures out which chunk it wants to expand, and then it triggers a tool call to fetch the chunk before and after the one it wants to expand on. So back to our tennis elbow example, this is the select a chunk that the agent might want to expand. So it would get the chunk before and it would get the chunk after. And here in lies the problem with this approach, which is that the agent has no idea what's before or after. That's why it's getting it, but it doesn't realize that it's still missing the context that it needs. So it doesn't know what it doesn't know, and that's the problem with this neighbor expansion approach. But this is also achievable in N8N.

So let's reset the session, ask the question again. Now I've prompted this agent to fetch the candidate chunks, select one, and then hit this "get neighbor chunks" tool to fetch the chunk before and after the one it's focused on. And as you can see, it's doing it a few times, and it has outputted the answer. Now, this answer doesn't look as comprehensive as the last answer because with the entire document in context, it was able to provide a very comprehensive answer. Here, it's a little bit sketchy in parts. But if we'd look into the mechanism, as usual, we fetch our candidate chunks from the vector store, and instead of document ID now, which we were focused on previously, we are getting line numbers in the metadata. And this comes as standard in N8N's vector store integrations. So we can see that for this chunk, it started on line number 222 and it ended on line number 252. So the idea here then is if we wanted to expand out this chunk, we simply need to search for chunks that have this document ID but that also have two set to 221 in this case, so decremented by one. And if you wanted to get the chunk after this, it needs to be from 253. And that's essentially what this query is doing. So you can take a screenshot of this if you like, but we're just loading up the ID of the chunk, the content, the metadata, where the metadata `doc_id` equals what's in the metadata, and then these line numbers. If it's minus one or plus one, then those are the neighboring chunks that you're looking for. Now, from looking at the data, it actually does take new lines into account. So if you have a lot of consecutive new lines in your file, this approach won't work perfectly, but typically speaking, chunks are separated by one or two new lines, and that's what we're covering here. And then just looking at the input and output, the agent sent in the document ID and it said that it wanted to expand out the chunk with line numbers 254 to 266. And what it got back then was the previous chunk, 222 to 252, and the next chunk, 268 to 285. So at least this way, it's able to expand out its view of the chunk a little bit to help better answer the question. But in reality, it would be a lot better if we could load up the entire section as opposed to arbitrary chunks before and after the chunk that it's focused on.

So what we'd like to do here then is based off the question that's asked, we get our candidate chunks, pick the one that we want to expand, and then we fetch all of the chunks in that section. So that's the goal. And what this would mean then is for the tennis elbow question, we would be able to get the entire policy exclusion section. And we now know this is not covered by this policy. And this is very much based off the inherent document structure that a lot of documents have, like policy documents, regulations, research papers, reports, the list is endless. I would wager that the majority of PDF and Word documents that are generated in enterprise have some level of a meaningful structure. So if we're able to extract out that structure, then not only could you get the section that a chunk belongs to, you could also get the parent. Because here, "coverage" is a heading one, let's say, "policy exclusions" is a heading two, and "tennis elbow" is in bold or it's a heading three. So we should be able to figure out that all of this is under "coverage" heading one. And that's essentially what parent expansion is. You're grabbing all of the content under the parent heading. So we're getting a lot more chunks back from the knowledge base now to provide comprehensive answers. And here we will be getting the full coverage section of the policy document.

So before I do a demo of this, let's just take a step back and look at how chunking actually works because the standard approach to chunking in N8N is to use what's known as a recursive character text splitter. Now, that's quite a mouthful, but it's actually quite simple once you understand what's going on. So here we have a default data loader in N8N, and under the splitter, we have a chunk size of 500 with a chunk overlap of 100. Now, the overlap is a rudimentary way of trying to understand what's in the previous chunk. So we can try to give an indication of what's come before. And the way it works then is I've got this 2,000-character document here, and if it was picking, say, 500 characters, it might get to there, for example, and then it's looking for breakpoints. So we have these delimiters, these breakpoints. So that's like, essentially, a paragraph is two new lines. A single new line is essentially just hitting return on your keyboard. And if the text doesn't have either of those, it just reverts to a space or even no space. So we get to, let's say, our 500 mark here, and it then looks backwards for two new lines, which is here. So instead of cutting it at this point, it'll actually cut it here. So this is our first chunk because it's delimited by these two new lines here. Now, to avoid complicating this, I'll just pretend the overlap is set to zero. But then the process continues. So it goes again, 500 characters. It gets somewhere around here. Tracks back to look for two new lines, which is there. And that's our second chunk. And so on and so forth. And that's fine for an unstructured document. There are no headings here.

But this is what it might look like for a structured document. And this is relatively crude, but you get the idea that these chunks aren't divided into natural structured sections in the document. So here, let's say the chunk is starting here in this double new line. It goes 500 characters and then it tracks back to the new line there. And this chunk essentially covers two different concepts: one is around dimensions, and the other is around suspension. So then when this chunk is actually embedded by the embedding model, it's not really going to strongly signal suspension or strongly signal dimensions because it's a little bit of both. And this isn't ideal because it is recommended that your chunks are focused on a particular topic. They shouldn't span multiple topics. And that is the real weakness of recursive character text splitting, particularly in a structured document. And you can see another example of it here where you have the end of one section and the start of another section all in this single chunk.

So a better approach then is to actually split by markdown. So instead of looking for new lines, you look for headings: heading two, heading three, heading four. So that way then, when you're processing this document, let's say this is one chunk, and then we're starting from this point. It would then go 500 characters ahead, which will get to here, and it'll track back looking for an H2 or a heading two, which will get to there, and it'll stop. So that way, we're not including information about the floor body in this chunk. So we're keeping it a little bit more focused. But we still have the same problem, though, because this chunk started in this section and now it's encompassing this dimension section as well. So, it is still better than standard recursive character text splitting, but it still has its failings. And the way you do this in N8N is within your text splitter, you just need to set markdown as the split mode, and then it'll look for those H2s and H3s and H4s when it's tracking back to look for a split point or a boundary. Alan has published a full video on this markdown splitting. If you're interested, I'll leave a link for this in the card above.

Okay, so you can still see our problem. The markdown splitting is better, but it's still not perfect. So really, what we need to do is actually split the entire document first based off markdown headings. So it's almost like creating subdocuments. And you can see up here, the delimiters would be headings two, three, and four. And if we take our document here, it'll now create the sections first. And this was our problem area before where suspension fairings was actually in the same chunk as dimensions. That won't happen now because we've separated that section from this section. Now, it's worth noting that this is a first pass, and you can see why here. This is a gigantic section. This is too big for any one chunk. So, the approach then is after you split your document by headings, you then carry out recursive character text splitting. So, in this case here, suspension fairings is isolated from dimensions. So, job done. And for this large section here, this is split into three different chunks that can be upserted to the vector store. So this is essentially the gold standard of chunking and splitting structured documents that have markdown headings. LangChain has a full how-to guide on this approach where you split first by headings and then follow it up with recursive character text splitting. The problem is, though, it's not supported by N8N. You can't use the default data loader for this. You have to create a custom chunker and then feed in those chunks into the vector store. And this is a major failing of N8N, and I really can't wait until they actually build this type of functionality in natively. But then again, the beauty of N8N is you can have your code nodes and actually just carry out the custom logic the way you want to do it.

So that's enough theory for the moment. Let's demo it, and then I can explain how it works. So same question, "How do I install the Impava oven?" I've hooked this up now to my section and parent expansion agent, and then it's now hitting this context expansion endpoint, and we have an answer, and it's quite a detailed answer, as you can see, and it's a little bit more like the answer we got when we were loading up the entire document. So what happened here is we searched as normal, selected our candidate chunk, and then in the context expansion endpoint, now we're hitting a Supabase edge function. I'll talk about that in a second. But what we're sending in is both the document ID and also a range of chunk indexes, as you can see here. So it's kind of similar to the last approach. Previously, I was sending in line numbers. Now I'm sending in the actual indexes of chunks. So the question is, how does the AI agent know that chunk indexes 19 to 25 represent this section or this parent? Well, if we have a look at what we're getting back from the vector store, we can see that for this returned chunk, we're now getting a child range and a parent range.

So, let's explain what's happening here with our new custom approach to chunking. We now have very rich metadata at a chunk level. So, in this example fragment of a document, the first thing to note is we can see this cascading path. So we know that the heading one is "Using Your Washer," and the heading two is "Begin Procedure." We can see that for this "Begin Procedure" section, it's actually split across two chunks. Chunk ID or chunk index 46 to 47. And then the parent chunk, "Using Your Washer," the chunk IDs range from 32 to 54. So, if you had an AI agent that retrieved this chunk and it wanted to expand out its context to the parent section of the document, the "Using Your Washer" section, it could load up all the chunks from 32 to 54. And that's what this edge function does. We're passing in the `doc_id`, which is this one here, and we're passing in the chunk ranges. And this is an array, so you can send in multiple different ranges if you want. So, as you can see, this is super powerful because here we were able to load up the parent range of this section of the document. And the answer that we're getting back is pretty similar to the answer we got back when we loaded up the entire document.

So, these three custom nodes are essentially calculating all of these IDs and indexes and the hierarchy to figure out what to inject into each chunk's metadata. And that's what you see in yellow. But you can take this a step further and actually use an LLM to analyze the document up front to actually enrich the metadata even further. And that's what you see in green. So we're able to extract out the brand, the appliance type, a small document summary, and inject it into the metadata. And that's done using this "enrich" call that happens per document. So it's not per chunk. It's not like contextual embeddings, which isn't really that scalable here. It's a single call per document. You only really need to send in the first few pages of the document as well to actually extract out some of this metadata. And the cherry on top, then, with this approach is you can almost achieve what you get with contextual embeddings. So if we go back to our AI agent and go back into this vector store node, you can see in the page content, it says, "This chunk is from an EPA 24-inch single wall oven instruction manual. Specifically, the installation section, part two." So that's not far off what an LLM would actually generate. And the way we're able to do that is that the first piece is simply just the document summary in five to eight words. And the second piece is just the section heading that this chunk lives in. So all of this is achieved using these three code nodes. And it's super scalable. It's not an LLM call for every single chunk. And if we go back to our tennis elbow example again, you wouldn't even need to expand out your context because the chunk would have a snippet which says, "This chunk is from the 2025 insurance policy, specifically the policy exclusion section." I have a full video on contextual embeddings and contextual retrieval. I'll leave a link for this in the card above.

I spent a few hours with Claude code creating and testing these scripts. Um, it was far from straightforward. Um, so I won't go through it in detail. There's about 200 lines of code here, but essentially, what's happening is the smart markdown chunker is first parsing by headings, then it's carrying out the recursive character text splitting within the larger sections. And what I found then is a problem that's both with the standard recursive character text splitting as well as my new approach is that you can end up with very small chunks. And this happens with the native markdown splitter in N8N as well. So I created a system to smartly merge really tiny chunks because, in a way, they were just polluting the vector store and they were being returned in place of better chunks because they were ultra-focused on, let's say, a heading. So after that splitting and merging, we added this heading prefix, this contextual snippet at the start of each chunk. And then the second and third nodes, they extract out this document hierarchy. So it's again parsing by headings and then it's carrying out a section to chunk mapping, which is quite complicated, and that way we can get the different chunk ranges for each heading in the document. Off the back of that, then we build out this hierarchical index. So this is what the hierarchy looks like. So for a washer manual, you can see the various heading ones, heading twos, heading threes, and you can see the ranges. So this "floor drain system" section, which is a heading three, is in chunk 10. And the parent of that is the "drain system," which ranges from 9 to 11. And the parent of that is "installation requirements," which ranges from chunk 3 to chunk 27. So this way, the agent is essentially able to navigate the actual document structure and load up whatever chunks it needs to answer the question. And that essentially is the final version of this. This is agentic expansion using a document hierarchy.

And what happens is the agent first off retrieves the golden chunk that contains the document ID. And instead of expanding from there, it then fetches the document hierarchy. That mapping that I just showed you. And this is really important because if the chunk actually references multiple parts of the document, the agent will then be able to actually pass in those chunk ranges to get back the exact right context to generate the answer. And this is really common in documents. You could have a paragraph that says, "As discussed in section two, as referenced in the definition section, in an appendix, the footnote." You don't necessarily need a knowledge graph to map all of this. A simpler document hierarchy with chunk indexes is enough for an agent to actually be able to pull the information it needs. And with all of the chunks in context, it can then generate the full response. So our final tennis elbow example, we pulled this candidate chunk. And from there, we were able to pull the section, the parent, and through the document hierarchy, other relevant sections within this document that may contain relevant information to produce a comprehensive answer.

So onto our final demo. We now have a "fetch document hierarchy" tool call. So we'll ask the same question again, "How do I install the oven?" We retrieve our candidate chunks. We hit the "fetch document hierarchy," and then we pass in the ranges into the context expansion endpoint in Supabase, and we generate the answer. Excellent. And a quick look at the hierarchy for this document. You can see it here with the different ranges, the different heading levels. And this was essentially used then to help inform the range that was passed in to the chunk expansion endpoint.

So let's have a quick look at this Supabase endpoint. So it's an edge function that essentially just triggers a database function. It's doing a little bit of validation, but really, what it's doing is just making sure that the data is in decent shape to send into this `get_chunks_by_ranges` database function. And that's what's looking for the `doc_id` and the array of chunk indexes. And then if we go to the database function, it's not too complicated. Essentially, we're just looping through each document in the array, extracting the document ID, looping through the different chunk ranges, extracting those as well, and then we're carrying out an SQL `SELECT` against those IDs, and then everything is just returned back through the edge function to the AI agent.

And another reason why Postgres is so good for this is you need somewhere to save this document hierarchy. You can't save it as a chunk in the vector store. So here, we're saving this in our record manager in Supabase. So if you've seen my N8N RAG Masterclass, you'll know that we use a record manager to keep track of the documents that have been upserted into our vector store so that we know that we're not duplicating information in there. So all I've done is I've just added a single column called `hierarchical_index`, and we're able to save this document structure and these chunk ranges in this cell. And that's essentially what we're just retrieving. So this isn't possible in Pinecone or in Quadrant. You will need a separate Postgres database to do this. But if you're using Supabase, you have the best of both worlds.

And this is what the full RAG ingestion would look like then. So you grab a new file based off a Google trigger. You loop over it because you can get multiple files per trigger call. You're just setting some basic data about the document. Here, we're using Mistral OCR because the native extract from PDF node in N8N does not extract headings. So you must use OCR for this. And from there, then we're actually aggregating page numbers, which I forgot to mention, but this is so cool because if you look at this metadata structure, we can actually see that this chunk was extracted from pages 11 and 12 of this document, which is crucial from a traceability perspective. Again, that is not supported natively out of the box with N8N. So from here, then we go into the record manager, and if it's a new document, we create a new row. If it's an existing document where the content has changed, we delete the previous vectors so that they can be ingested again. And from there, then we go into our document and metadata enrichment, which is an LLM call. That gives us that rich markdown that we can then inject into the chunks along with the chunk ranges. And from there, then we're just injecting them directly into the Supabase vector store after generating the embeddings. Now, we could use the native Supabase vector store node here. However, it doesn't perform that well when you're actually custom chunking like this. That was one of our key findings from our RAG at Scale video, which I'll link in the description as well. But this idea of injecting directly into the vector store via the Postgres node works very well if you have chunk-specific metadata at scale.

And finally, we're going to be building this approach into our state-of-the-art RAG system, which is available in our community, the AI Automators. Currently, the system just has a standard recursive character text splitter based off markdown in this custom code node. So, we'll be dropping in our new smart markdown generator and document hierarchy extractor. So, that'll be dropped in there. And this system also supports tabular data, knowledge graphs, up to 100 different file formats, as well as dynamic hybrid search, which I covered in my last video. Check out the link in the description for more.

I hope this video was helpful in explaining this concept of context expansion and how it really is vital if you want to get accurate and comprehensive answers. If you'd like to see more videos like this, then make sure to subscribe to our channel. And don't forget to give the video a like below. It really helps us out.