Python 现代开发学习笔记

(四)把笔记整理成能够回查的文本块

1. 同时保存文本与来源

直接把整本笔记放进每次请求,会反复处理许多与问题无关的内容。先把材料整理成 Document:page_content 放文字,metadata 放来源、归属和块编号。这个类型从 langchain_core.documents 导入,它把文本与解释这份文本的字段放在同一对象中。后面检索到一段文字时,还能知道它来自哪份笔记、当前用户能否阅读。

RAG 是 Retrieval-Augmented Generation,即检索增强生成。一次问答中,程序先找材料,再把问题与材料一起交给生成模型。它改变的是本次输入上下文;收录一篇新笔记并没有自动重新训练模型权重。检索与 RAG

2. 沿文本结构分块

先只处理三句话:“切片共享数据。修改会影响原数组。复制后再修改。”。逐句长度是 7、9、7,总长 23。把一块的长度设为 16,能够放下前两句;再加入第三句就会变成 23,因此先交出当前块。

下一块从哪里开始,取决于允许携带多少尾部文字。重叠上限设为 9 时,上一块尾部的第二句长 9,可以留下,再接第三句,得到另一块长 16 的文字。这两个块分别覆盖原文的 [0,16) 和 [7,23),共享的是中间那句。

RecursiveCharacterTextSplitter 从 langchain_text_splitters 导入。它依次尝试给定分隔符,将较小的片段组合成块;组合仍放不下一段文字时,才继续使用更细的分隔方式。下面只给句号和空字符串两种分隔,便于逐字核对;keep_separator="end" 让句号留在句子末尾。

"""用三个已知长度的句子,核对分块与整句重叠。"""
from langchain_text_splitters import RecursiveCharacterTextSplitter

sentences = ["切片共享数据。", "修改会影响原数组。", "复制后再修改。"]
text = "".join(sentences)
print("句长:", list(map(len, sentences)))  # 7、9、7,总长 23。
# 句号留在前一句末尾;长度用 Python 的 len 计算。
splitter = RecursiveCharacterTextSplitter(
    chunk_size=16, chunk_overlap=9, separators=["。", ""], keep_separator="end",
)
chunks = splitter.split_text(text)
assert chunks == [sentences[0] + sentences[1], sentences[1] + sentences[2]]
print("重叠上限 9:", chunks)
# 上限改成 8 后,长度为 9 的整句不能留下,第二块只剩第三句。
shorter = RecursiveCharacterTextSplitter(
    chunk_size=16, chunk_overlap=8, separators=["。", ""], keep_separator="end",
).split_text(text)
assert shorter == [sentences[0] + sentences[1], sentences[2]]
print("重叠上限 8:", shorter)
先放入长为7和9的句子,遇到第三句后交出首块,保留9字尾句并形成下一块

再看代码中的第二次分块:重叠上限改成 8,第二句长 9,无法作为一个完整片段留下,于是下一块只含第三句。重叠参数决定可以携带的长度,实际留下的文字还受分隔单位影响。把原句、块内容和长度一起打印,就能直接看出发生了哪次舍弃。

3. 将文本块连回原文

现在将这一步接回完整资料。项目的 resources/notes.json 保存了来源、归属和文本。importlib.resources.files 从安装包内部读取附带资源,因此安装 wheel 后也能找到它。项目先按段落、换行、句号等拆分,块长使用 120,重叠上限使用 20。

# 对照完整文件阅读这一段;此处保留当前步骤使用的函数或类型。
def load_chunks() -> list[Document]:
    """来源、归属和块编号与文本一起保存,回答时才能引用。"""
    raw = files("studyagent").joinpath("resources/notes.json").read_text(encoding="utf-8")
    records = json.loads(raw)
    splitter = RecursiveCharacterTextSplitter(chunk_size=120, chunk_overlap=20,
                                             separators=["\n\n", "\n", "。", ",", " ", ""])
    chunks = []
    for record in records:
        for index, text in enumerate(splitter.split_text(record["text"])):
            chunk_id = f'{record["source"]}:{index}'
            chunks.append(Document(id=chunk_id, page_content=text,
                                   metadata={"source": record["source"], "owner": record["owner"], "chunk_id": chunk_id}))
    return chunks

每完成一块,就创建一个 Document,同时写入 source、owner 和 chunk_id。enumerate 给出同一来源内部的块序号,numpy.md:0 表示该笔记的第 0 块。id 标识存储中的文档,metadata 中再保存 chunk_id,后面取回材料时就能直接构造引用。

“切片共享原数组”和“需要独立修改时先 copy”要一起解释,读到前句的材料还应能找到后句。先检查实际取回的块,再调整分块或增加相邻块,比只把更多不相关材料塞进提示更容易定位问题。项目的 demos/chunks.py 会打印八个实际块的编号、归属和长度,供这里逐项对照。

笔记正文与全部题解 · Ctrl / ⌘ K