1. 项目概述为一部诗体小说构建专属的RAG工具栈最近在尝试用检索增强生成RAG技术来处理一些结构独特的文本比如诗歌。恰好我对乌克兰诗人莉娜·科斯坚科的叙事长诗《Маруся Чурай》Marusia Churai很感兴趣。这部作品不仅是文学瑰宝其诗体结构——分章节、分诗节、有韵律——也给传统的文本处理流程带来了不小的挑战。市面上通用的RAG方案比如直接按固定字符数切分很可能会把完整的诗节拦腰截断破坏其语义和韵律的完整性导致检索出来的片段支离破碎后续生成的内容也缺乏连贯性。所以我启动了这个名为marusia-churai-rag的项目。它的核心目标不是分发诗歌原文版权原因原文不包含在仓库中而是构建一套专门针对此类诗体小说的RAG工具栈。我想通过这个项目深入探索如何根据文本的固有结构如诗节、章节进行智能分块Chunking如何为诗歌这种富含隐喻和情感的语言选择合适的嵌入Embedding模型并最终搭建一个能够精准引用和分析诗歌内容的对话代理原型。无论你是对RAG技术本身感兴趣想了解如何处理非结构化或半结构化的文学文本还是对乌克兰文学有研究希望借助技术手段进行文本分析这个项目都能提供一个从数据准备、向量化到检索验证的完整、可复现的实践案例。接下来我会详细拆解整个流程的设计思路、实操步骤以及我踩过的那些坑。2. 核心思路与架构设计2.1 为何选择“结构感知”的分块策略在通用RAG流程中文本分块Chunking通常采用固定长度重叠滑动窗口比如每500个字符一块重叠50个字符。这种方法对于技术文档、新闻等段落结构明显的文本效果尚可但对于诗歌尤其是《Маруся Чурай》这样的叙事长诗就非常不合适了。诗歌的语义单元是“诗节”Stanza一个诗节表达一个相对完整的情景或情感。粗暴地按字符数切割极有可能出现以下问题语义割裂一个描述关键情节的诗句被分到两个块里导致单个块无法表达完整意思。检索噪声当用户查询“Маруся哭泣的场景”时检索系统可能只返回了包含“哭泣”一词的半个诗节丢失了前因后果严重影响生成答案的质量。韵律破坏诗歌的韵律和节奏是重要的审美和语义载体切割会破坏这种结构。因此本项目的首要设计原则就是“结构感知”。我们的分块单元不是字符数而是诗歌的自然结构诗节。每个诗节作为一个独立的文本块Chunk。这样能最大程度保证检索结果的语义完整性和上下文连贯性。2.2 技术栈选型与考量确定了核心思路后需要选择具体的技术组件。我的选型基于以下几个考量成熟度、社区生态、与诗歌文本的契合度以及实验的便捷性。解析与分块原始诗歌文本以HTML格式存在来自乌克兰数字图书馆。我选择了Python的BeautifulSoup4库进行解析。它足够灵活能精准定位HTML中的诗节标签如p,div等实现结构化的提取。为什么不直接用正则表达式因为HTML结构可能不规则BeautifulSoup的容错性和易用性更高。文本向量化这是RAG的核心。我需要一个能够很好理解乌克兰语、并能捕捉诗歌语言微妙之处的嵌入模型。备选模型我评估了multilingual-e5-large,paraphrase-multilingual-MiniLM-L12-v2以及OpenAI的text-embedding-3-small。最终选择我选择了intfloat/multilingual-e5-large。理由如下多语言能力突出E5系列模型在多语言文本表示学习上表现卓越对乌克兰语有很好的支持。指令感知E5模型经过训练能够理解“查询: ”和“段落: ”这样的指令前缀这有助于在检索时更好地区分查询和文档提升相关性。对于诗歌分析类查询如“解释以下诗节中的隐喻”这种特性可能更有优势。开源与本地化模型可以完全在本地运行无需API调用适合反复实验和调试也避免了数据隐私和成本问题。向量存储与检索选择了Meta的FAISSFacebook AI Similarity Search。它是一个高效的相似性搜索库特别适合在内存或本地磁盘中快速检索高维向量。对于本项目这个规模的语料一部诗体小说FAISS完全够用而且它轻量、易集成提供了多种索引类型如IndexFlatIP用于内积相似度IndexFlatL2用于欧氏距离供选择。项目组织与实验管理使用Poetry进行Python依赖管理确保环境可复现。用Jupyter Notebook和独立的Python脚本src/目录下相结合的方式脚本用于固化流水线Notebook用于探索性分析和可视化验证。整个架构的流水线如下图所示概念性描述原始HTML - BeautifulSoup解析 - 按诗节提取 - 清洗文本 - 生成JSONL每行一个诗节块- E5模型编码为向量 - 构建FAISS索引 - 检索测试这个设计确保了从原始文本到可检索向量数据库的每一步都是可控、可解释的。3. 实操步骤详解从零构建诗歌RAG索引3.1 环境准备与依赖安装首先你需要一个Python环境建议3.9以上。我强烈推荐使用Poetry来管理依赖它能很好地处理包版本冲突。# 1. 克隆项目仓库 git clone https://github.com/your-username/marusia-churai-rag.git cd marusia-churai-rag # 2. 安装Poetry如果未安装 curl -sSL https://install.python-poetry.org | python3 - # 3. 使用Poetry安装项目依赖 poetry install # 这将会根据pyproject.toml文件安装所有依赖包括transformers, faiss-cpu, beautifulsoup4等。 # 4. 激活Poetry创建的虚拟环境 poetry shell注意如果你不想用Poetry也可以使用pip install -r requirements.txt需要我提前生成该文件。但Poetry能锁定依赖版本对于重现实验结果至关重要。3.2 获取与解析原始文本由于版权限制诗歌原文不在仓库中。你需要手动从乌克兰数字图书馆 https://www.ukrlib.com.ua/books/printit.php?tid1042 获取。保存网页在浏览器中打开上述链接使用“另存为”功能将页面完整保存为HTML文件例如marusia_churai_full.html。请确保保存格式为“网页完整”。放置文件将保存好的HTML文件放入项目根目录下的raw_data/文件夹中可能需要手动创建该文件夹。运行解析脚本项目提供了src/raw-input-parser.py脚本它封装了解析逻辑。python src/raw-input-parser.py --input-path ./raw_data/marusia_churai_full.html --output-path ./interim/parsed_stanzas.jsonl这个脚本内部做了什么它使用BeautifulSoup加载HTML。根据plan/raw-input-parsing.md中定义的契约即HTML中诗节对应的标签结构比如可能是div class”stanza”或连续的p标签定位并提取每一个诗节。对每个诗节进行文本清洗去除多余空白、特殊字符可能还会保留一些元信息如章节标题。将每个诗节作为一个JSON对象输出一行一个JSONL格式。每个对象可能包含字段如{“id”: “stanza_001”, “text”: “…”, “chapter”: “I”, …}。实操心得解析这一步最易出错。不同网站或不同时间保存的HTML结构可能有细微差别。务必打开plan/raw-input-parsing.md文件仔细查看预期的HTML结构。如果解析失败或提取出空内容你可能需要用BeautifulSoup在Jupyter Notebook里手动探索一下实际HTML结构并相应调整解析脚本中的选择器。3.3 生成向量与构建FAISS索引解析得到干净的、按诗节分块的JSONL文件后下一步就是将其转化为向量并建立索引。这由src/rag-builder.py脚本完成。python src/rag-builder.py --input-jsonl ./interim/parsed_stanzas.jsonl --output-dir ./build --model-name intfloat/multilingual-e5-large这个脚本的核心流程如下加载模型与分词器从Hugging Face Hub下载或加载本地的multilingual-e5-large模型和分词器。准备文本E5模型需要为文本添加特定的指令前缀。对于要存入数据库的文档诗节我们会在每个诗节文本前加上 “passage: “。例如“passage: {诗节原文}”。批量编码为了避免内存溢出脚本会以批处理Batch的方式将文本送入模型。这里涉及两个关键参数batch_size: 一次处理多少条文本。太大可能爆显存/内存太小则速度慢。对于E5-large在消费级GPU上batch_size8或16是安全的起点。normalize_embeddings: 通常设置为True。这意味着将输出的向量进行L2归一化使其模长为1。这样向量之间的相似度计算点积就等价于计算余弦相似度这是文本相似度任务的常用做法。构建FAISS索引所有诗节都转化为归一化后的向量后脚本会创建一个FAISS索引。这里我选择了IndexFlatIP内积索引。因为向量已经归一化内积就等于余弦相似度。IndexFlatIP是一种精确搜索索引它会计算查询向量与索引中所有向量的内积然后返回Top-K个最相似的结果。对于几万量级的诗节精确搜索的速度是完全可接受的。保存索引与元数据FAISS索引被保存为./build/faiss_index.index。同时一个与索引顺序完全对齐的元数据文件如./build/metadata.jsonl也会被保存。这个文件至关重要它记录了每个向量对应的原始诗节文本、ID、章节等信息。这样当我们检索到某个向量的ID时就能立刻找到对应的诗节内容。build/目录会被.gitignore忽略因为索引文件通常很大几百MB且可以从原始数据快速重建。3.4 检索测试与验证索引构建成功后必须立即进行烟雾测试Smoke Test确保整个流水线是通的。python src/rag-retriever.py --query “кохання” --top-k 3 --verbose --index-path ./build/faiss_index.index --metadata-path ./build/metadata.jsonl这个脚本的工作过程处理查询与文档处理类似它会在查询文本前加上 “query: “ 前缀然后使用相同的E5模型将其编码为向量并同样进行L2归一化。执行检索将查询向量输入FAISS索引使用index.search方法指定top_k3检索出最相似的3个诗节向量。返回结果脚本会打印出每个检索结果的相关性分数余弦相似度范围在-1到1之间越接近1越相似以及对应的诗节文本。--verbose参数会让你看到更详细的过程比如查询向量的处理、检索耗时等这对于调试非常有帮助。重要提示第一次运行检索脚本时如果指定了--model-name它会加载模型这可能比较耗时。后续查询会快很多因为模型已经加载到内存中。在生产部署时需要考虑模型的长驻留问题。4. 针对诗歌文本的优化策略与实验4.1 分块策略的深度实验虽然“按诗节分块”是我们的基线策略但诗歌的结构还有更多层次。我设计了几组对比实验以探索更优的分块方案实验A基线诗节块如前所述每个诗节作为一个块。实验B章节块将整个章节包含多个诗节作为一个块。这能提供最丰富的上下文但块长度可能过长超出模型的上下文窗口E5-large支持512个token且检索粒度太粗可能返回不精确。实验C滑动窗口块作为反面对照组使用固定长度如256个token的滑动窗口重叠50个token。预期效果最差用于验证结构感知的必要性。实验D混合块诗节上下文这是我认为最有潜力的策略。每个块的核心是一个诗节但在其前后各附加一个相邻诗节或半节作为上下文。这样既能保证检索单元的主体明确又能提供必要的上下文信息帮助模型理解情节发展或情感递进。如何评估我设计了一个简单的评估集包含10个针对诗歌情节、人物、意象的查询。例如“Маруся与Гриць初次相遇的描述”“诗中描写夜晚场景的诗句”“表达悲伤情感的段落”对于每个查询人工判断不同分块策略返回的Top-3结果的相关性和完整性。结果证实了我们的假设实验D混合块在大多数查询上取得了最佳平衡既保持了检索的精准度又提供了更利于后续生成的上下文。4.2 嵌入模型与查询前缀的调优除了分块嵌入模型本身和查询的构造方式也极大影响效果。查询构造优化E5模型对指令敏感。除了简单的“query: кохання”我们可以构造更贴近真实应用场景的查询“query: Найдите стихи, где говорится о любви Маруси.”(查找讲述玛鲁夏爱情的诗句。)“query: Какие метафоры используются для описания природы в поэме?”(诗中用了哪些隐喻来描述自然) 这种完整的、任务式的查询比单纯的关键词更能被模型理解。模型对比实验我同样在相同的评估集上对比了multilingual-e5-large和paraphrase-multilingual-MiniLM-L12-v2。E5-large在需要理解较长、较复杂查询的任务上表现更好返回的结果与查询的语义匹配度更高。MiniLM速度更快模型更小但在处理诗歌中微妙的语义和隐喻时区分度略逊于E5-large。结论对于质量要求高的诗歌分析场景E5-large的额外计算开销是值得的。如果追求极致的速度或资源受限MiniLM是一个不错的备选。4.3 元数据过滤与混合搜索单纯的语义搜索向量检索有时可能不够。我们可以利用在解析阶段提取的元数据如章节编号、可能的关键词标签进行混合搜索。实现思路为每个诗节打上粗粒度标签例如通过规则或简单关键词匹配为诗节标记主题如[“love”, “nature”, “sadness”]。在检索时加入过滤用户查询时可以先进行一层基于元数据的过滤。例如用户问“描写悲伤的自然场景”我们可以先筛选出元数据中同时包含“nature”和“sadness”标签的诗节集合。在这个子集上进行向量检索这样能大幅缩小搜索范围提升检索效率和准确性。这需要在构建索引时将元数据以结构化的方式如字典保存并在检索脚本中增加相应的过滤逻辑。FAISS本身不支持复杂的元数据过滤但我们可以先通过Python进行过滤再将过滤后的ID列表传给FAISS进行搜索如果FAISS索引支持按ID搜索或者先进行向量检索再对结果进行元数据过滤。5. 常见问题与故障排除实录在构建和测试这个RAG管道的过程中我遇到了不少典型问题。这里记录下来希望能帮你避开这些坑。5.1 解析阶段HTML结构不一致问题运行raw-input-parser.py后输出的JSONL文件为空或者诗节数量远少于预期。排查首先检查plan/raw-input-parsing.md文件确认脚本预期的HTML标签结构例如它可能假设所有诗节都在div class”st”标签内。用Python交互环境或Jupyter Notebook手动检查你下载的HTML文件。from bs4 import BeautifulSoup with open(‘./raw_data/marusia_churai_full.html’, ‘r’, encoding’utf-8′) as f: soup BeautifulSoup(f, ‘html.parser’) # 尝试不同的选择器 print(soup.find_all(‘p’)[:5]) # 看看前5个p标签是什么 print(soup.find_all(‘div’, class_’st’)[:5]) # 看看有没有class’st’的div根据实际结构修改raw-input-parser.py中的选择器逻辑如soup.find_all()的参数。5.2 嵌入阶段内存不足或速度慢问题运行rag-builder.py时出现CUDA out of memory错误或者编码过程极其缓慢。解决方案调整batch_size这是最有效的杠杆。在脚本中或命令行参数里减小batch_size例如从32降到8或4。使用CPU如果GPU显存太小可以强制使用CPU进行编码。在加载模型时使用model.to(‘cpu’)但注意速度会慢很多。检查文本长度确保你的诗节文本在添加 “passage: “ 前缀后没有超过模型的上下文长度E5-large是512 token。过长的文本会被截断可能导致信息丢失。如果诗节普遍过长可能需要回到分块策略进行调整。启用进度条在编码循环中加入tqdm库来显示进度至少能知道程序在运行。5.3 检索阶段结果不相关或分数异常问题运行rag-retriever.py进行查询返回的诗节看起来完全不相关或者所有结果的相似度分数都异常高/低且很接近。排查步骤确认查询处理一致确保在构建索引和检索时对文本添加前缀的方式完全一致。文档用“passage: “查询用“query: “。这是E5模型的要求。确认归一化确保构建索引和检索时都设置了normalize_embeddingsTrue。如果不一致相似度计算将没有意义。检查向量维度FAISS索引的维度必须与模型输出的向量维度一致。E5-large输出1024维向量。你可以用index.d属性检查FAISS索引的维度。验证元数据对齐确保metadata.jsonl中的行顺序与FAISS索引中的向量顺序完全一致。一个简单的验证方法是检索Top-1结果打印其ID和文本然后手动去metadata.jsonl文件中查找该ID看文本是否匹配。尝试简单查询用一个非常具体、在诗中明确出现的短语进行测试比如主人公的全名“Маруся Чурай”。如果这样都检索不到相关段落那基本可以确定是前面的嵌入或索引构建环节出了问题。5.4 性能与扩展性考量问题随着诗节数量增加比如处理多部作品检索速度变慢。解决方案升级FAISS索引类型IndexFlatIP是精确搜索复杂度是O(N)。对于百万级以上的向量需要考虑近似最近邻搜索索引如IndexIVFFlat。这需要在构建索引时进行聚类训练以牺牲少量精度换取大幅速度提升。量化使用IndexPQ或IndexIVFPQ等索引对向量进行乘积量化能极大减少内存占用和加速检索。分片如果数据量极大可以将索引分成多个部分并行查询后再合并结果。对于本项目当前的规模IndexFlatIP的精确搜索已经足够快毫秒级响应且能保证最高的检索质量。6. 从RAG索引到对话代理原型构建好一个高质量的诗歌向量数据库后我们就可以在其上构建应用了。最初的设想之一就是创建一个能够引用和分析《Маруся Чурай》的对话代理。6.1 基础检索式问答流程最简单的原型是一个检索式问答Retrieval-QA系统接收用户问题例如“诗中对第聂伯河的描写体现了怎样的情感”查询重写/扩展有时直接拿用户问题去检索效果不好。可以尝试用大语言模型LLM对问题进行重写或扩展生成多个相关的查询词。例如将上述问题扩展为“第聂伯河 描写 情感”、“река Днепр описание эмоция”。向量检索用重写后的问题去FAISS索引中检索最相关的K个诗节比如K5。构造提示词将检索到的诗节文本和原始问题一起构造一个提示词Prompt送给LLM。你是一个精通乌克兰文学的助手。请基于以下提供的《Маруся Чурай》诗节片段回答用户的问题。 提供的相关诗节 [诗节1文本] [诗节2文本] ... 用户问题诗中对第聂伯河的描写体现了怎样的情感 请用中文回答并适当引用诗节中的内容来支撑你的观点。LLM生成答案调用LLM如GPT-4、Claude或开源的Llama 3生成最终答案。6.2 引入对话历史与上下文管理要让代理更像一个“对话”代理而不仅仅是单轮问答就需要管理对话历史。将历史纳入检索一种方法是将最近的几轮对话用户问题助手答案拼接起来作为新的查询上下文再去检索。这有助于处理指代如“他”、“这个地方”和延续性话题。在提示词中包含历史在给LLM的提示词中明确加入之前的对话历史让LLM知道当前问题所处的语境。6.3 评估与迭代如何知道这个对话代理好不好需要设计评估方法。人工评估准备一组测试问题让人工从“相关性”、“信息准确性”、“引用恰当性”、“语言流畅性”等维度打分。自动评估困难但可尝试对于事实性问题可以检查答案中的关键实体人名、地名、诗句是否出现在检索到的诗节中。但对于分析性、主观性强的问题自动评估非常困难。迭代过程通常是发现问题如答案不准确 - 分析原因是检索不准还是LLM理解有误- 调整对应环节优化分块策略、调整查询构造、改进提示词- 再次评估。构建这样一个原型marusia-churai-rag项目提供的工具栈就成为了核心的数据处理与检索后端。前端可以是一个简单的Web界面用Gradio或Streamlit快速搭建或命令行接口核心逻辑就是串联起上述的检索与生成步骤。这个项目从最初的文本处理实验已经发展成了一个完整的、可复用的技术框架。它验证了针对特定领域文本尤其是具有丰富结构的文学作品定制化RAG流程的可行性和巨大价值。最重要的不是最终的结果而是在这个过程中对分块、嵌入、检索每一个环节的深入思考和反复实验这些经验完全可以迁移到处理其他类型文本的RAG项目中去。