CrabNote螃蟹笔记

构建无向量RAG系统(无需嵌入向量,无需向量数据库)

构建无向量RAG系统(无需嵌入向量,无需向量数据库)

构建无向量RAG系统(无需嵌入向量,无需向量数据库) 构建无向量RAG系统(无需嵌入向量,无需向量数据库) Modified March 28 Code block Python import json import openai from .node import PageNode client = openai.OpenAI() SUBSECTION THRESHOLD = 300 words def segment(text: str) list: prompt = f"""Split the following text into logical sections. Return a JSON object with a "sections" key. Each item has: "title": short title (5 words or less) "content": the text belonging to this section Text: {text[:8000]}""" response = client.chat.completions.create( model="gpt 5.4", messages=[{"role": "user", "content": prompt}], max completion tokens=3000, response format={"type": "json object"}, ) parsed = json.loads(response.choices[0].message.content) return parsed.get("sections", []) def parse document(text: str) PageNode: root = PageNode(title="root", content="", summary="", depth=0) for item in segment(text): title = item.get("title", "Section") content = item.get("content", "") node = PageNode(title=title, content="", summary="", depth=1) node.parent = root word count = len(content.split()) if word count SUBSECTION THRESHOLD: subsections = segment(content) if len(subsections) 1: for sub in subsections: child = PageNode( title=sub.get("title", "Subsection"), content=sub.get("content", ""), summary="", depth=2, ) child.parent = node node.children.append(child) else: node.content = content splitting gave nothing useful, keep as leaf else: node.content = content short enough to stay as a leaf root.children.append(node) return root 在此之后,短章节是带有内容的叶子节点。长章节是带有子章节的内部节点。此时所有摘要字段均为空。索引器接下来会填充这些字段。 步骤4:构建摘要(pageindex/indexer.py) 我们以后序遍历(子节点先于父节点)的方式遍历树。每个叶子节点总结其自身内容。每个内部节点(如根节点,或任何有子部分的部分)从其子节点的总结中构建出一个总结。后序遍历保证每个子节点在其父节点需要之前都有一个总结。 Code block Python import openai from .node import PageNode client = openai.OpenAI() def summarize(text: str, section name: str = "") str: hint = f"This is the section titled: {section name}.\n" if section name else "" prompt = f"""{hint}Summarize the following in 2 3 sentences. Be specific and factual. Do not add anything not in the text. {text[:3000]}""" response = client.chat.completions.create( model="gpt 5.4 mini", messages=[{"role": "user", "content": prompt}], max completion tokens=150, ) return response.choices[0].message.content.strip() def build summaries(node: PageNode): post order: children first for child in node.children: build summaries(child) if node.is leaf(): if node.content.strip(): node.summary = summarize(node.content, node.title) else: node.summary = "(empty section)" else: build parent summary from children's summaries children text = "\n\n".join( f"[{c.title}]: {c.summary}" for c in node.children ) node.summary = summarize(children text, node.title) 在执行 build summaries(root) 之后,树中的每个节点都有一个有意义的摘要。 我们将树序列化为JSON,这样就只需构建一次。 步骤 5:保存和加载索引(pageindex/storage.py) 我们将树序列化为JSON,这样就只需构建一次。 Code block Python import json from .node import PageNode def save(node: PageNode, path: str): def to dict(n: PageNode) dict: return { "title": n.title, "content": n.content, "summary": n.summary, "depth": n.depth, "children": [to dict(c) for c in n.children], } with open(path, "w") as f: json.dump(to dict(node), f, indent=2) def load(path: str) PageNode: def from dict(d: dict) PageNode: node = PageNode( title=d["title"], content=d["content"], summary=d["summary"], depth=d["depth"], ) for child dict in d["children"]: child = from dict(child dict) child.parent = node node.children.append(child) return node with open(path) as f: return from dict(json.load(f)) 步骤6:通过树搜索检索(pageindex/retriever.py) 从根节点开始,大语言模型(LLM)读取子节点的摘要并选择最佳分支。如果该子节点是内部节点(有子部分),我们就在该层级重复此过程。我们持续进行,直到遇到叶子节点。while循环可以处理任意深度。 Code block Python import openai from .node import PageNode client = openai.OpenAI() def pick child(query: str, node: PageNode) PageNode: options = "\n".join( f"{i + 1}. [{c.title}]: {c.summary}" for i, c in enumerate(node.children) ) prompt = f"""You are navigating a document tree to find the answer to a question. Current section: "{node.title}" Question: {query} Children of this section: {options} Which child section most likely contains the answer? Reply with only the number.""" response = client.chat.completions.create( model="gpt 5.4 mini", messages=[{"role": "user", "content": prompt}], max completion tokens=5, ) try: index = int(response.choices[0].message.content.strip()) 1 return node.children[index] except (ValueError, IndexError): return node.children[0] def retrieve(query: str, root: PageNode) str: node = root while not node.is leaf(): if not node.children: break node = pick child(query, node) return node.content 步骤7:将其整合(main.py) Code block Python import os from pageindex.parser import parse document from pageindex.indexer import build summaries from pageindex.retriever import retrieve from pageindex import storage import openai client = openai.OpenAI() INDEX PATH = "index.json" def build index(doc path: str): print("Parsing document...") text = open(doc path).read() tree = parse document(text) print("Building summaries (this makes LLM calls)...") build summaries(tree) print(f"Saving index to {INDEX PATH}") storage.save(tree, INDEX PATH) return tree def ask(query: str) str: if not os.path.exists(INDEX PATH): raise FileNotFoundError("Index not found. Run build index() first.") tree = storage.load(INDEX PATH) context = retrieve(query, tree) response = client.chat.completions.create( model="gpt 5.4", messages=[{ "role": "user", "content": f"Answer using only the context below.\n\nContext:\n{context}\n\nQuestion: {query}" }], max completion tokens=500, ) return response.choices[0].message.content.strip() if name == " main ": First time: build the index build index("document.md") Then query it print(ask("Your Question")) 索引的结构示例 运行 build index 后,打开 index.json,你会看到类似如下内容: Code block JSON 在此之后,短章节是带有内容的叶子节点。长章节是带有子章节的内部节点。此时所有摘要字段均为空。索引器接下来会填充这些字段。 步骤4:构建摘要(pageindex/indexer.py) 我们以后序遍历(子节点先于父节点)的方式遍历树。每个叶子节点总结其自身内容。每个内部节点(如根节点,或任何有子部分的部分)从其子节点的总结中构建出一个总结。后序遍历保证每个子节点在其父节点需要之前都有一个总结。 在执行 build summaries(root) 之后,树中的每个节点都有一个有意义的摘要。 我们将树序列化为JSON,这样就只需构建一次。 步骤 5:保存和加载索引(pageindex/storage.py) 我们将树序列化为JSON,这样就只需构建一次。 步骤6:通过树搜索检索(pageindex/retriever.py) 从根节点开始,大语言模型(LLM)读取子节点的摘要并选择最佳分支。如果该子节点是内部节点(有子部分),我们就在该层级重复此过程。我们持续进行,直到遇到叶子节点。while循环可以处理任意深度。 步骤7:将其整合(main.py) 索引的结构示例 运行 build index 后,打开 index.json,你会看到类似如下内容: 短章节保持为深度为1的叶子节点。长章节(如“运输选项”)成为内部节点,其子章节子节点深度为2。检索逐层进行,直到找到叶子节点。 常见问题 大语言模型(LLM)总是选错分支。你的摘要太模糊了。在 summarize 中尝试使用更强的模型,或者在提示中添加更多细节。 大语言模型(LLM)分段在不恰当的位置切割文本。当parse document处理长文档时,有时会在句子中间进行分割。可通过增加分段调用中的max tokens参数来解决这个问题,或者在发送前将文档拆分为约3000字的段落。 叶子内容非常长。如果一个叶子的内容超过约1500个token,则降低SUBSECTION THRESHOLD,以便更多的部分被拆分为子部分。 完整代码: https://github.com/vixhal baraiya/pageindex rag 如果你觉得这个有帮助,别忘了给点个⭐。 Keep building. Keep learning. 🔗 原文链接: https://x.com/TheVixhal/status/2037... https://x.com/TheVixhal/status/2037... 本文中,我们将借助分层页面索引技术,构建一套无需向量、基于推理的RAG系统。该系统会把文档转化为树形结构,再由大语言模型(LLM)通过推理遍历该树形结构以获取答案,全程无需嵌入向量,也无需相似度检索。 这与我们在现实生活中查找信息的方式十分相似。当你想在课本中查找某个知识点时,不会从头逐页翻阅,而是先打开目录,找到对应章节,查看其下的小节,再直接定位到所需内容。 页面索引(PageIndex)的工作原理亦是如此。你向其输入一份文档,它会将文档构建为树形结构——每个分支对应一个章节,每个叶节点对应具体文本内容;当你提出问题时,大语言模型便会逐层遍历这棵树,最终找到准确答案。 完整代码: https://github.com/vixhal baraiya/pageindex rag (如果你觉得这个有帮助,别忘了点个⭐星哦。) 计划 在我们敲下第一行代码之前,这是完整的计划。 步骤1:将文档解析为层次化树形结构。 我们将文档发送给大语言模型(LLM),并要求它将文本拆分为顶级章节。然后,对于每个足够长、可以进一步拆分的章节,我们再次将其发送给大语言模型,以获取子章节。这就为我们生成了一个多级树结构。短章节保持为叶子节点。长章节则成为带有子节点的内部节点。 步骤2:自下而上总结每个节点。 我们从叶子节点到根节点遍历树结构。每个叶子节点都会得到一个由大语言模型生成的简短的原始文本摘要。每个内部节点都会得到一个由其子节点摘要构建的摘要。根节点最终会得到整个文档的摘要。 步骤3:保存索引。 我们将树序列化为一个JSON文件。这就是索引。我们构建一次,然后重复使用它。 步骤4:通过遍历树来检索。 在查询时,我们从根节点开始。我们向大语言模型展示所有子节点的摘要,并询问应该进入哪一个。我们移动到该子节点。我们重复这个过程,直到到达一个叶子节点。叶子节点的原始文本就是我们检索到的上下文。 步骤5:生成答案。 我们将检索到的上下文和问题传递给大语言模型(LLM),然后得到答案。 架构 让我们来看看数据是如何在系统中流动的。 索引时间(运行一次) 查询时间(每个问题的运行次数) 既然我们已经知道要构建什么以及各个部分如何组合在一起,那就开始编写代码吧。 步骤1:设置项目 创建它: 步骤2:定义节点(pageindex/node.py) 文档的每个部分都成为一个PageNode。它存储一个标题、原始文本、我们稍后生成的摘要以及它的子节点。 步骤3:解析文档(pageindex/parser.py) 我们分两步构建树。首先,我们让大语言模型(LLM)将整个文档拆分为顶级章节。然后,对于任何足够长、值得进一步拆分(超过300字)的章节,我们将其发回大语言模型并获取子章节。短章节保持为叶子节点。长章节则成为带有子节点的内部节点。 segment是执行一级拆分的辅助函数。parse document会调用它两次:一次针对整个文档,一次针对每个长段落。