RAG经典文本问答系统实战:以圣典AI问答为例 之前在做经典文本类的 AI 问答应用时最头疼的问题不是模型能力不够而是模型“一本正经地胡说八道”。尤其是面向宗教经典、哲学文献这类带有严格语境和翻译体系的文本通用大模型如果没有外部知识支撑很容易把不同章节、不同译者的内容混在一起。最近看到 Sikhbani.ai 这类“Ask the Sri Guru Granth Sahib”项目本质上就是一个典型的 RAGRetrieval-Augmented Generation应用先对圣典原文做语料清洗和切分再通过向量检索把最相关的段落找出来最后交给大模型生成回答。这篇文章不讨论宗教教义只从工程角度拆解如何构建一个面向 Sri Guru Granth Sahib 的 AI 问答系统。我会从核心概念、环境准备、数据切分、向量检索、生成链路、完整代码示例到常见问题和生产建议完整走一遍。如果你是做知识库问答、文档助手、RAG 应用开发或者想给特定语料做一个 AI 问答机器人这篇文章的思路可以直接迁移。1. 背景与核心概念1.1 Sri Guru Granth Sahib 是什么为何需要 AI 问答Sri Guru Granth Sahib 是锡克教的重要经典内容包含大量诗歌、哲学论述、历史叙事和灵性教导。它的文本结构比较特殊通常按照章节、作者Bhagat / Guru、乐章Raga、行号等方式组织。传统上信徒会通过 Shabad圣诗检索、关键字搜索等方式查阅内容但这种方式要求用户知道关键词或章节名称使用门槛比较高。“Ask the Sri Guru Granth Sahib”希望做到的是用户用自然语言提问比如“什么是服务Sewa”“关于谦卑有什么教导”系统自动从圣典中找到相关段落并生成回答。这正好是大型语言模型LLM和检索增强生成技术的典型应用场景。难点在于圣典文本不能随意让模型自由发挥必须保证回答有原文依据并且需要保留引用来源。1.2 RAG 是什么为什么适合经典文本问答RAG 的全称是 Retrieval-Augmented Generation即检索增强生成。它的核心思想是不直接让大模型凭空回答而是先从外部知识库中检索出与问题最相关的片段把这些片段作为上下文连同问题一起交给大模型生成答案。用公式表示就是Answer LLM(Question Retrieved_Context)之所以 RAG 适合经典文本问答有四个原因减少幻觉大模型不是数据库它不会精确记住每一行原文。通过检索把原文片段注入上下文模型只需要做“阅读理解 回答”而不是“默写原文”。可追溯可以返回来源段落、章节编号方便用户核对。知识更新容易圣典语料可能涉及不同译本、不同罗马拼音标注方式只要更新知识库即可不用重新训练模型。控制输出边界可以在提示词中要求模型只基于检索到的内容回答超出范围就拒绝适合内容敏感的文本场景。1.3 项目总体架构整个系统的技术链路可以拆成两条流程离线索引流程原始文本 → 清洗 → 分块 → 向量化 → 存入向量数据库在线问答流程用户提问 → 向量化 → 向量检索 → 拼接上下文 → LLM 生成 → 返回答案和引用从工程组件上看主要包含数据层原始圣典文本、清洗脚本、分块策略。索引层Embedding 模型、向量数据库FAISS / Chroma / Qdrant 等。问答层Prompt 模板、大模型接口、引用格式化。应用层FastAPI / Flask 接口、前端聊天界面。下面我会从环境准备开始逐步实现一个简化版但可运行的系统。2. 环境准备与版本说明2.1 运行环境建议使用 Linux 或 macOSWindows 也可以跑但需要注意路径分隔符和编码问题。示例代码基于 Python 3.9。如果本机还没装 Python建议先用 conda 或 pyenv 管理环境。python --version如果你看到的是 Python 3.8 或更早版本建议先升级。本文示例以常见环境为例重点演示配置思路具体版本号请根据你的实际环境调整。2.2 安装依赖需要安装的库主要有langchain封装 RAG 链路提供文本加载、切割、检索、Prompt 等能力。langchain-openai对接 OpenAI 接口的 LangChain 集成包。openaiOpenAI Python SDK。faiss-cpu本地向量检索库轻量且容易上手。chromadb向量数据库也可以用来做持久化。tiktokenOpenAI 的 token 计数工具方便控制分块大小。fastapi和uvicorn用来搭建问答 API。pandas辅助处理结构化元数据。安装命令pip install langchain langchain-openai openai faiss-cpu chromadb tiktoken fastapi uvicorn pandas如果你的网络环境无法直接安装可以使用国内镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple langchain langchain-openai openai faiss-cpu chromadb tiktoken fastapi uvicorn pandas2.3 大模型与 Embedding 模型选择这个项目需要两类模型Embedding 模型把文本转换为向量用于检索。常见选择有text-embedding-3-small、text-embedding-3-large、bge-large-zh等。如果对英文和罗马拼音文本检索推荐 OpenAI 的text-embedding-3-small效果稳定成本低。生成模型用于最终回答。可以使用gpt-4o-mini、gpt-4o也可以使用本地部署的 Qwen、Llama 等模型。如果希望完全本地运行可以选用 Ollama 或 vLLM 部署开源模型。需要特别提醒的是不要假设所有模型都支持任意格式的文本。OpenAI 的 Embedding 模型对输入长度有限制例如 8192 token所以分块长度不能设置得过大。2.4 API Key 配置如果使用 OpenAI 接口需要在环境变量中配置 API Keyexport OPENAI_API_KEYsk-xxxx在 Python 代码里可以通过os.getenv(OPENAI_API_KEY)读取。如果使用其他兼容 OpenAI 协议的国内模型可以在ChatOpenAI中指定base_url例如from langchain_openai import ChatOpenAI llm ChatOpenAI( modelyour-model-name, api_keyyour-api-key, base_urlhttps://your-endpoint.com/v1 )不要在代码里硬编码密钥更不要提交到 Git 仓库。3. 核心原理拆解从圣典文本到 RAG 问答3.1 文本清洗原始语料不能直接用Sri Guru Granth Sahib 的原始文本可能有多种格式Gurmukhi 原文、罗马拼音转写、英文翻译、逐行注释等。如果我们直接把这些内容塞进大模型会产生几个问题噪音太多。页码、目录、注释、标记符号会干扰 Embedding 质量。语义被割裂。一行原文和一行翻译如果被切成两个块检索时很难对齐。重复内容。圣典中有些 Shabad 会重复出现如果不做去重检索结果可能全是同一段内容。清洗阶段要做的事情包括去除页眉页脚、页码、目录。合并“原文 翻译 注释”为一条完整记录而不是打散成多个段落。统一空白字符、换行符。如果只有 Gurmukhi 原文没有翻译需要额外考虑是否要对原文做拼音转写或保留原文。这里有一个关键判断最终需要大模型基于什么语言回答问题。如果面向英文用户那么知识库最好包含英文翻译如果面向旁遮普语用户则可以保留 Gurmukhi 原文。Sikhbani.ai 这类项目的常见做法是同时保留原文、罗马拼音和英文翻译检索时用英文翻译展示时附带原文。3.2 文本分块ChunkingRAG 质量的第一道关卡分块是 RAG 链路中最影响效果的一环。块太大检索结果不精准且会超出模型上下文窗口块太小语义不完整模型无法理解上下文。对于圣典类文本建议使用“语义块”而不是简单的固定长度切分每个 Shabad 作为一个基本单位。如果单个 Shabad 太长再按段落切分。保证一个块内包含完整的“原文 翻译 元数据”避免只有翻译没有原文。保留元数据例如source_page、shabad_id、first_line、author。示例分块策略伪代码def split_into_blocks(records, max_tokens500): blocks [] current_block current_meta None for rec in records: rec_text fOriginal: {rec[original]}\nTranslation: {rec[translation]} if len(current_block) len(rec_text) max_tokens: current_block \n\n rec_text current_meta rec else: blocks.append({text: current_block, metadata: current_meta}) current_block rec_text current_meta rec if current_block: blocks.append({text: current_block, metadata: current_meta}) return blocks这里要注意不要把原文和翻译切到不同的块中否则检索到“翻译”时不知道对应的原文是什么回答的引用会很不专业。3.3 Embedding如何把文本变成向量Embedding 模型将文本映射到一个高维向量空间语义相近的文本在向量空间中距离也更近。RAG 的检索阶段就是用这个特性找到“语义上最相关”的段落。在使用 Embedding 模型时要注意以下事项查询文本也要做相同的向量化处理。不能检索用模型 A查询用模型 B。向量维度需要和向量数据库保持一致。如果中途切换模型向量维度变化会导致旧数据无法使用。对长文本要控制长度尽量使用与分块策略一致的 tokenizer。下面是一个使用 OpenAI Embedding 的示例from langchain_openai import OpenAIEmbeddings embeddings OpenAIEmbeddings( modeltext-embedding-3-small, api_keyos.getenv(OPENAI_API_KEY) ) vector embeddings.embed_query(What does Sikhism say about service?) print(len(vector)) # 1536 for text-embedding-3-small3.4 向量存储与检索FAISS 和 Chroma 的区别对于文档量不大的场景FAISS 是非常好的选择。它运行在本地支持内存索引和磁盘持久化查询速度快。Chroma 则更像一个完整的向量数据库支持元数据过滤、持久化、增量写入。选择依据数据量小于 100 万条FAISS 足够。需要按元数据如作者、章节过滤Chroma 更方便。需要部署成独立的数据库服务推荐 Qdrant / Milvus / Weaviate。如果只是学习或演示FAISS 是最简单的方式。检索时除了向量相似度还可以结合关键词过滤。例如用户指定只查某位作者的作品可以在检索前先做元数据过滤减少无关结果。3.5 Prompt 设计控制模型只说有依据的话RAG 的最后一步是让大模型基于检索上下文生成答案。如果 Prompt 不限制模型仍可能凭自己的“知识”自由发挥。对经典文本场景必须在 Prompt 中明确以下三点只基于提供的上下文回答问题。如果上下文不包含答案明确回答“没有找到相关信息”。回答中需要引用原文编号或来源。一个参考 Prompt 模板You are a helpful assistant for the Sri Guru Granth Sahib. Answer the users question using ONLY the provided context. If the context does not contain the answer, say I could not find relevant verses. Include the source references at the end of your answer. Context: {context} Question: {question}把这段模板放在 LangChain 的ChatPromptTemplate中即可。4. 完整实战案例构建一个 Sikhbani.ai 风格问答系统现在我们从零开始实现一个最小可运行的项目。为了便于理解我把项目拆成几个文件。你可以先按下面的结构创建目录。4.1 创建项目结构sikhbani-rag/ ├── data/ │ └── sample_gurbani.csv ├── scripts/ │ ├── ingest.py │ └── query.py ├── app/ │ └── main.py ├── requirements.txt └── .envdata/sample_gurbani.csv用来存放已经清洗好的语料。由于我们这里只是演示我会构造几条示例数据结构包含original、translation、author、shabad_id四列。4.2 准备示例语料创建data/sample_gurbani.csvshabad_id,author,original,translation SGGS-1,Nanak,ik onkar sat naam kartaa purakh nirbhau nirvair akaal moorat,There is One God. His name is Truth. He is the Creator, without fear and without hate. SGGS-2,Nanak,sochai soch na hovai jee soi,By thinking He cannot be reduced to thought. SGGS-3,Kabir,kaal kare so aaj kar aaj kare so ubah,Do today what must be done today; do not postpone it.注意这里的“原始文本”是罗马拼音转写不是 Gurmukhi 原文字符。在实际项目中建议保留 Gurmukhi 原文字段这里为了演示方便做了简化。如果你手头有真实的圣典语料请确保你拥有合法使用权限。4.3 编写索引脚本创建scripts/ingest.pyimport os import pandas as pd from dotenv import load_dotenv from langchain.docstore.document import Document from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS load_dotenv() def load_gurbani_data(csv_path: str): df pd.read_csv(csv_path) documents [] for _, row in df.iterrows(): text ( fOriginal: {row[original]}\n fTranslation: {row[translation]}\n fAuthor: {row[author]}\n fShabad ID: {row[shabad_id]} ) doc Document( page_contenttext, metadata{ shabad_id: row[shabad_id], author: row[author], } ) documents.append(doc) return documents def build_index(csv_path: str, index_path: str): docs load_gurbani_data(csv_path) # 按字符或 token 切分这里示例用 RecursiveCharacterTextSplitter text_splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , ], ) chunks text_splitter.split_documents(docs) embeddings OpenAIEmbeddings( modeltext-embedding-3-small, api_keyos.getenv(OPENAI_API_KEY), ) vectorstore FAISS.from_documents(chunks, embeddings) vectorstore.save_local(index_path) print(fIndex saved to {index_path}, total chunks: {len(chunks)}) if __name__ __main__: build_index(data/sample_gurbani.csv, data/gurbani_index)这段代码做了以下事情读取 CSV 语料。把每一行转换成Document对象page_content是拼接后的完整文本metadata中保存 ID 和作者信息。对文档进行切分。这里虽然是示例但切分器依然是工程中常用的RecursiveCharacterTextSplitter。使用 OpenAI Embedding 模型向量化。保存 FAISS 索引到本地目录。如果你使用的不是 OpenAI 接口可以替换OpenAIEmbeddings为本地的HuggingFaceEmbeddings只需要保证模型下载到本地即可。4.4 编写问答脚本创建scripts/query.pyimport os from dotenv import load_dotenv from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_community.vectorstores import FAISS from langchain.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough load_dotenv() PROMPT_TEMPLATE You are a helpful assistant for the Sri Guru Granth Sahib. Answer the users question using ONLY the provided context. If the context does not contain the answer, say I could not find relevant verses. Include the source Shabad ID and author at the end of your answer. Context: {context} Question: {question} def main(): embeddings OpenAIEmbeddings( modeltext-embedding-3-small, api_keyos.getenv(OPENAI_API_KEY), ) vectorstore FAISS.load_local( data/gurbani_index, embeddings, allow_dangerous_deserializationTrue, ) retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 3} ) llm ChatOpenAI( modelgpt-4o-mini, api_keyos.getenv(OPENAI_API_KEY), temperature0.1, ) prompt ChatPromptTemplate.from_template(PROMPT_TEMPLATE) # 构建 LCEL 链 rag_chain ( {context: retriever, question: RunnablePassthrough()} | prompt | llm ) while True: question input(\n请输入问题输入 exit 退出) if question.strip().lower() exit: break result rag_chain.invoke(question) print(\n回答) print(result.content) # 打印来源信息 docs retriever.get_relevant_documents(question) print(\n来源片段) for doc in docs: print(f- {doc.metadata.get(shabad_id)} / {doc.metadata.get(author)}) if __name__ __main__: main()说明几个关键点allow_dangerous_deserializationTrue是 FAISS 本地加载时的安全参数。加载不可信的索引文件时不要开启这里只是本地演示。search_kwargs{k: 3}表示每次检索返回 3 个片段。temperature设置成 0.1降低生成多样性让回答更稳定。打印来源是为了让用户核对答案是否来自圣典原文。4.5 运行与验证先运行索引脚本cd sikhbani-rag python scripts/ingest.py输出类似Index saved to data/gurbani_index, total chunks: 3然后运行问答脚本python scripts/query.py输入问题请输入问题What does Nanak say about God?系统会检索到包含Nanak和God相关语义的片段然后生成回答并打印来源。如果输入的问题在语料中没有答案比如“What does the Guru say about finance?”模型应该回答“I could not find relevant verses.”而不是自己编造。下面是一个可能的输出回答 According to the provided context, Guru Nanak describes God as the Creator, without fear and without hate. The verse begins with ik onkar sat naam kartaa purakh nirbhau nirvair akaal moorat. 来源片段 - SGGS-1 / Nanak - SGGS-2 / Nanak4.6 使用 FastAPI 封装问答接口上面的命令行脚本只适合本地验证。实际部署时我们需要提供 HTTP 接口。创建app/main.pyimport os from fastapi import FastAPI from pydantic import BaseModel from dotenv import load_dotenv from langchain_openai import OpenAIEmbeddings, ChatOpenAI from langchain_community.vectorstores import FAISS from langchain.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough load_dotenv() app FastAPI(titleSikhbani RAG API) embeddings OpenAIEmbeddings( modeltext-embedding-3-small, api_keyos.getenv(OPENAI_API_KEY), ) vectorstore FAISS.load_local( data/gurbani_index, embeddings, allow_dangerous_deserializationTrue, ) retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 3} ) llm ChatOpenAI( modelgpt-4o-mini, api_keyos.getenv(OPENAI_API_KEY), temperature0.1, ) prompt ChatPromptTemplate.from_template( You are a helpful assistant for the Sri Guru Granth Sahib. Answer the users question using ONLY the provided context. If the context does not contain the answer, say I could not find relevant verses. Include the source Shabad ID and author at the end of your answer. Context: {context} Question: {question} ) rag_chain ( {context: retriever, question: RunnablePassthrough()} | prompt | llm ) class QueryRequest(BaseModel): question: str app.post(/ask) def ask(request: QueryRequest): result rag_chain.invoke(request.question) docs retriever.get_relevant_documents(request.question) sources [ { shabad_id: doc.metadata.get(shabad_id), author: doc.metadata.get(author), content: doc.page_content, } for doc in docs ] return { answer: result.content, sources: sources, }启动服务uvicorn app.main:app --host 0.0.0.0 --port 8000调用接口curl -X POST http://localhost:8000/ask \ -H Content-Type: application/json \ -d {question: What does Kabir say about procrastination?}返回的 JSON 中包含答案和来源片段。这样前端就可以直接对接了。5. 常见问题与排查思路在实际开发中RAG 项目很容易出现“效果不好”的问题。这里我整理了一张常见问题排查表。问题现象常见原因解决思路回答完全与原文无关Embedding 模型与查询文本语言不一致确认文档和问题使用同一种语言检查 embedding 模型是否匹配检索结果总是同一块内容分块太大导致多个查询命中同一大块文档去重不彻底减小 chunk_size增加去重逻辑回答编造原文Prompt 没有限制只能使用上下文temperature 设置过高在 Prompt 中增加严格限制温度设置为 00.2加载 FAISS 索引报错FAISS 版本不一致索引路径错误重新生成索引检查 FAISS 版本尽量保持一致中文问题检索效果差Embedding 模型对中文支持不佳换用支持中文的 Embedding 模型或对查询做翻译预处理API 调用超时搜索返回片段过多Prompt 过长减少 k 值限制上下文长度使用流式输出优化体验文件编码乱码CSV 或文本文件编码不一致统一使用 UTF-8 编码读取时指定encodingutf-8数据量增大后检索变慢内存索引未持久化或向量数据库选择不当使用支持磁盘索引的向量数据库如 Chroma / Qdrant引用来源无法显示metadata中没有保存来源字段在构建 Document 时把shabad_id、author写入 metadata如果你遇到“模型回答看起来有道理但还是不对”的情况优先检查检索召回的质量。可以把检索到的片段打印出来看看这些片段是否真的和问题相关。很多时候问题不在大模型而在前面的切分和向量化。6. 最佳实践与工程建议6.1 数据清洗与来源管理面向经典文本的 RAG 系统数据质量意味着一切。我建议在索引中加入以下元数据字段shabad_id: 唯一标识 author: 作者/贡献者 source: 译本来源 language: 文本语言 first_line: 首行内容 last_line: 末行内容 url: 在线来源链接如果有这样不仅方便溯源还可以在前端展示“参考来源”时显示更多信息。对于多译本数据建议为每个译本单独建一条记录并在检索阶段通过元数据过滤掉不需要的译本。6.2 分块策略的细节不要盲目采用固定窗口切分。对圣典类文本更推荐的策略是以 Shabad 为最小不可分割单元。如果 Shabad 过长按段落切分但每个块仍要保留 Shabad ID。保证上下文有重叠避免切断语义。chunk_overlap可以设置为chunk_size的 10%20%。在切分前用 tokenizer 计算真实 token 数而不是简单按字符切分。以下是一个计算 token 数量的示例import tiktoken enc tiktoken.encoding_for_model(gpt-4o) text Your verse text here tokens enc.encode(text) print(len(tokens))6.3 检索增强混合检索与重排序纯向量检索在某些场景下不够稳定尤其是当用户输入的是专有名词例如某个作者的名字时关键词匹配可能更准确。生产级系统通常使用“混合检索”向量检索处理语义相似问题。BM25 / 关键词检索处理专有名词和精确匹配。重排序Rerank把两种结果合并后用 cross-encoder 或 LLM 重新打分。在 LangChain 中可以使用EnsembleRetriever组合 BM25 和向量检索from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever bm25_retriever BM25Retriever.from_documents(docs) bm25_retriever.k 2 vector_retriever vectorstore.as_retriever( search_kwargs{k: 2} ) ensemble_retriever EnsembleRetriever( retrievers[bm25_retriever, vector_retriever], weights[0.5, 0.5] )重排序可以使用CrossEncoderReranker但对小项目来说先把混合检索做好已经能提升不少效果。6.4 Prompt 安全与内容合规由于项目涉及宗教经典回答内容必须谨慎。除了技术上的 RAG 链路外建议增加以下保护回答必须基于上下文禁止臆测。不回答与圣典无关的问题例如“如何攻击别人”“如何获取违禁内容”。不做教义评判不比较不同宗教。如果用户询问的内容超出语料范围应明确拒绝而不是强行生成。在 Prompt 中增加一句If the question is not related to the Sri Guru Granth Sahib, say I can only answer questions based on the Sri Guru Granth Sahib.同时在应用层可以加一个简单的关键词或分类模型判断问题域减少无意义调用。6.5 生产部署注意事项环境变量管理不要把 API Key 写到代码里使用.env或云平台的 Secret Manager。索引更新圣典语料如果更新了需要重新跑索引脚本而不是直接修改向量库。日志与监控记录每次问答的question、retrieved_context、answer、latency方便排查问题。限流与缓存对重复问题做缓存减少大模型调用成本。模型降级如果主模型不可用可以自动切换到备用模型保证服务可用性。7. 总结与学习路线通过本文的拆解和代码实现你已经掌握了构建一个面向 Sri Guru Granth Sahib 的 AI 问答系统的核心流程清洗语料、语义分块、向量化、构建 FAISS 索引、通过 LangChain 搭建 RAG 链路、用 FastAPI 封装接口并了解了生产环境中常见的效果问题和排查思路。接下来如果你想继续深入可以从这几个方向入手切换成本地模型用 Ollama 或 vLLM 部署 Qwen / Llama替换 OpenAI API实现完全本地化的知识库问答。学习向量数据库把 FAISS 替换成 Chroma 或 Qdrant掌握元数据过滤、持久化和分布式部署。研究重排序在检索后增加 Rerank 环节观察效果提升幅度。完善前端给问答接口套一个简单的聊天界面支持 Markdown 和引用来源展示。最后给你一个非常实际的建议在做任何经典文本类 RAG 项目时先花 60% 的时间整理数据和设计分块策略再花 20% 的时间调检索最后才是大模型生成。数据链路做好了模型只要“按图索骥”就能给出可靠回答。如果你在实际搭建过程中遇到其他报错或效果问题欢迎按本文第 5 节的排查表逐步对照。练手项目不需要追求大而全先把最小闭环跑通再逐步优化你很快就能拥有一套属于自己的 AI 经典问答系统。