资讯动态

RAG实战:基于LangChain和FAISS构建垂直领域问答系统

发布时间:2026/8/31 5:22:28 来源:尧图企业网站定制
构建垂直领域问答系统时最大的问题不是大模型不会说话而是模型不知道该以哪段文本为依据。Sikhbani.ai 这类面向 Sri Guru Granth Sahib 的问答助手目标是从锡克教经典原文中检索相关内容再用自然语言回答用户的问题。如果直接把这本大部头经典塞进大模型提示词成本高、响应慢回答还容易偏离原文。更可维护的方案是检索增强生成RAG先把原文切块、向量化并存入向量数据库提问时先从库中检索相关片段再由大模型基于这些片段生成回答。这篇文章记录如何从零实现一个面向特定经典文本的 AI 问答系统重点讲解文本切分、向量检索、提示词组装、结果验证和线上部署。文中代码可以直接复制修改也可以作为学习 RAG 的参考实现。1. 先拆解这类问答应用的技术架构1.1 一句话理解 RAG检索增强生成Retrieval-Augmented GenerationRAG可以理解为给大模型一次开卷考试先查资料再作答。它由检索器Retriever和生成器Generator组成。检索器从知识库中找到与用户问题最相关的若干文本片段生成器把这些片段作为上下文结合用户问题生成答案。对于 Sri Guru Granth Sahib 这类结构固定、语言偏向古代旁遮普语或早期英语翻译、文本量很大的经典RAG 有两个明显优势。第一回答有据可查每个答案都能追溯到原文片段第二文本版本更新时只要重新生成索引即可无需重新训练模型。1.2 为什么不用微调微调适合教会模型某种“说话方式”不适合让模型记忆精确文本内容。如果目标是“这段经文在第几章”“这句话的单词是什么意思”微调模型很容易记错而且每次文本改版都要重新训练成本很高。RAG 把知识放在外部数据库模型只负责概括、翻译和表达因此更可控。当用户问到某一术语时模型看到的不是自己脑中的模糊记忆而是从知识库检索回来的原文上下文。这也意味着数据质量直接决定回答质量而不是模型参数大小。1.3 一个最小系统由哪些部分组成一个可运行的经典文本问答系统至少包含四个部分模块职责典型工具语料处理清洗文本、统一格式、标记来源Python、正则表达式切分与嵌入将长文本切块生成向量LangChain TextSplitter、sentence-transformers向量存储与检索保存向量并做相似度查询FAISS、Chroma、Milvus、Qdrant生成根据上下文生成回答Ollama 本地模型或云端大模型 API应用接口暴露对话和查询 APIFastAPI、Streamlit下面的示例会使用 Python LangChain FAISS Ollama 实现最小闭环。学习环境使用本地小模型生产环境可以替换为更强的模型代码结构不需要大幅调整。2. 环境准备与数据约定2.1 技术栈与版本建议使用 Python 3.10 以上版本。以最小依赖方式安装pip install langchain langchain-community langchain-huggingface faiss-cpu sentence-transformers fastapi uvicorn如果使用 Ollama 本地模型还需要安装 Ollama 并拉取模型。这里以qwen2.5:7b为例也可以根据语料语言换成llama3.1或deepseek-r1等模型ollama pull qwen2.5:7b依赖版本建议锁在 requirements.txt 中避免版本漂移。以下是撰写本文时相对稳定的组合langchain0.1.20 langchain-community0.0.38 langchain-huggingface0.0.3 faiss-cpu1.8.0 sentence-transformers2.7.0 fastapi0.111.0 uvicorn0.30.0注意版本号只是示例实际安装前需要确认它们与你使用的 Python 版本和操作系统兼容。2.2 数据文件结构准备语料时建议按目录和元数据文件管理原始文本。示例结构如下data/ src/ page_001.txt page_002.txt ... metadata.csvmetadata.csv保存每个文本文件的来源信息便于后续每个检索片段都能追溯到原始位置。最小示例file,book,section,language page_001.txt,Guru Granth Sahib,001,punjabi page_002.txt,Guru Granth Sahib,002,punjabi真实项目中file可以是文件路径section可以是章号或页号language字段可以帮助后续做语言识别和路由。2.3 文本清洗原始文本往往带有页码标记、OCR 噪声、多余空格和编码问题。清洗的目标是降低噪声而不是改变内容语义。一个通用清洗函数如下import re import unicodedata def clean_text(raw: str) - str: text unicodedata.normalize(NFKC, raw) text text.replace(\r\n, \n) text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t], , text) text re.sub(r第\s*\d\s*页, , text) return text.strip()关键点NFKC规范统一了全角半角字符适合处理翻译文本中的标点差异。不要用过于激进的规则删除字符某些符号可能对原文语义重要。清洗前后要抽样对比确认没有误删内容。对于演示可以先准备一个sample.txt放入两句通用文本The soul is pure; service is the path. Truth is above everything; truthful living is the highest.这些句子只用于跑通流水线接入真实语料时按相同流程处理即可。3. 构建知识库索引3.1 文本切分策略模型输入有长度限制向量检索也需要合适的粒度。切分过小一个完整语境会被拆散切分过大向量会包含太多无关主题降低检索精度。常用做法是按段落和语义边界切分例如使用 LangChain 的RecursiveCharacterTextSplitterfrom langchain.text_splitter import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , ], ) chunks splitter.split_text(text)参数含义参数作用推荐值chunk_size单个片段最大字符数400-800chunk_overlap相邻片段重叠字符数50-100separators按优先级切分的位置段落、句号、逗号对于经典文本500 字符左右通常能保留一个大意的完整段落重叠 50 字符可以避免句子在边界处被切断。3.2 Embedding 模型选型Embedding 模型将文本映射为高维向量。同一个语义越相近向量距离越近。常见选择模型语言支持特点all-MiniLM-L6-v2英文为主轻量适合原型bge-large-en-v1.5英文检索效果好模型较大bge-m3多语言支持中英等多语检索text-embedding-3-small多语言云端 API响应快本地模型使用 HuggingFaceEmbeddings 封装from langchain_huggingface import HuggingFaceEmbeddings embeddings HuggingFaceEmbeddings( model_namesentence-transformers/all-MiniLM-L6-v2 )首次运行会下载模型参数之后会缓存在本地。如果实际语料包含旁遮普语和英语建议使用BAAI/bge-m3这类多语言模型。3.3 FAISS 向量库的创建与保存FAISS 是一个文件型向量库不需要额外部署服务适合学习和单机原型。使用 LangChain 封装创建索引from langchain.vectorstores import FAISS vectorstore FAISS.from_documents(documents, embeddings) vectorstore.save_local(vectorstore)加载索引vectorstore FAISS.load_local( vectorstore, embeddings, allow_dangerous_deserializationTrue )注意allow_dangerous_deserialization存在原因是因为 FAISS 索引文件使用 pickle 序列化加载不可信文件可能有安全风险。自己生成的索引可以开启来自第三方或经过传输的文件需要谨慎处理。3.4 完整索引脚本 ingest.py把加载、清洗、切分、向量化、保存串起来import os import pandas as pd from langchain.docstore.document import Document from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_huggingface import HuggingFaceEmbeddings from langchain.vectorstores import FAISS DATA_DIR data/src METADATA_CSV data/metadata.csv INDEX_DIR vectorstore def load_text(file_path: str) - str: with open(file_path, r, encodingutf-8) as f: return f.read() def build_documents(): metadata pd.read_csv(METADATA_CSV) docs [] for _, row in metadata.iterrows(): file_path os.path.join(DATA_DIR, row[file]) if not os.path.exists(file_path): continue raw load_text(file_path) cleaned clean_text(raw) docs.append(Document(page_contentcleaned, metadatarow.to_dict())) return docs def main(): raw_docs build_documents() splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , ], ) chunks splitter.split_documents(raw_docs) print(fRaw docs: {len(raw_docs)}, chunks: {len(chunks)}) embeddings HuggingFaceEmbeddings(model_namesentence-transformers/all-MiniLM-L6-v2) vectorstore FAISS.from_documents(chunks, embeddings) vectorstore.save_local(INDEX_DIR) if __name__ __main__: main()代码说明split_documents会保留每个片段对应的 metadata便于后续溯源。FAISS.from_documents会先调用 embedding 模型为每个片段生成向量再存入索引。save_local会生成索引文件和映射文件目录可以整体备份。3.5 索引阶段的常见坑切分大小不合适是第一个坑。比如 200 字符的 chunk 可能把一个完整问题涉及的多句话切散导致检索不到2000 字符的 chunk 又可能让一个片段包含多个主题降低相关性。建议先统计语料平均段落长度再决定chunk_size。第二个坑是忘记保留 metadata。没有 metadata回答即使正确也无法告诉用户来源这在经典文本场景中几乎不可接受。第三个坑是索引和查询使用了不同 embedding 模型。“看起来都是文本向量”但不同模型生成的向量空间不一致相似度检索会彻底失效。4. 实现问答链路4.1 检索与生成的连接用户提问后系统需要完成三件事将问题用同一个 embedding 模型编码成向量。在向量库中检索最相似的 top-k 文本片段。将文本片段组装成提示词交给大模型生成回答。检索决定了模型能看到什么提示词决定了模型如何回答。两者至少要同时调优。4.2 检索代码使用similarity_search_with_score可以同时拿到片段和距离分数def retrieve(query: str, k: int 5): docs vectorstore.similarity_search_with_score(query, kk) return docs返回结果是一个由(Document, score)组成的列表。score 越小表示距离越近。如果使用余弦距离score 范围通常在 0 到 2 之间如果使用点积需要看具体 embedding 模型。4.3 提示词设计提示词是防止幻觉的关键。对经典文本问答建议要求模型只使用资料片段并明确禁止自行补充。示例PROMPT_TEMPLATE 你是一位熟悉经典文本研究助手。请仅根据下面提供的资料片段回答问题。 如果资料片段不足以回答问题请明确说“根据现有资料无法回答”。不要使用资料之外的记忆。 资料片段 {context} 问题{question} 回答这里特别加入了“不要使用资料之外的记忆”因为大模型即便没有检索到相关内容也可能靠预训练知识编造回答。加上这句约束后模型会更倾向引用或者拒绝。4.4 LLM 封装本地模型可以用 Ollamafrom langchain_community.llms import Ollama llm Ollama(modelqwen2.5:7b, temperature0.1)如果使用云端模型也可以通过 OpenAI 兼容接口替换例如from langchain_openai import ChatOpenAI import os llm ChatOpenAI( modelgpt-4o-mini, temperature0.1, api_keyos.environ[OPENAI_API_KEY], )把temperature设置为 0.1 是为了让回答更稳定如果后续需要创意性解释可以适度调高但对于“引用原文回答”的场景稳定优先。4.5 完整问答脚本 query.pyfrom langchain_huggingface import HuggingFaceEmbeddings from langchain.vectorstores import FAISS from langchain_community.llms import Ollama embeddings HuggingFaceEmbeddings(model_namesentence-transformers/all-MiniLM-L6-v2) vectorstore FAISS.load_local( vectorstore, embeddings, allow_dangerous_deserializationTrue ) llm Ollama(modelqwen2.5:7b, temperature0.1) PROMPT_TEMPLATE 你是一位熟悉经典文本研究助手。请仅根据下面提供的资料片段回答问题。 如果资料片段不足以回答问题请明确说“根据现有资料无法回答”。不要使用资料之外的记忆。 资料片段 {context} 问题{question} 回答 def answer(question: str, k: int 5) - str: docs vectorstore.similarity_search_with_score(question, kk) context \n\n.join( f片段 {i}:\n{doc.page_content} for i, (doc, _) in enumerate(docs, 1) ) prompt PROMPT_TEMPLATE.format(contextcontext, questionquestion) return llm.invoke(prompt)如果llm.invoke(prompt)返回的是带格式的结构需要根据具体模型处理。例如ChatOpenAI可能返回AIMessage需要取.content。4.6 把问答封装成 FastAPI 服务生产环境需要对外提供接口这里使用 FastAPI 实现一个简单/askfrom fastapi import FastAPI from pydantic import BaseModel app FastAPI() class AskRequest(BaseModel): question: str top_k: int 5 class SourceItem(BaseModel): source: str chunk: str score: float class AskResponse(BaseModel): answer: str sources: list[SourceItem] app.post(/ask, response_modelAskResponse) def ask(req: AskRequest): docs vectorstore.similarity_search_with_score(req.question, kreq.top_k) context \n\n.join( f片段 {i}:\n{doc.page_content} for i, (doc, _) in enumerate(docs, 1) ) prompt PROMPT_TEMPLATE.format(contextcontext, questionreq.question) answer_text llm.invoke(prompt) sources [ SourceItem( sourcedoc.metadata.get(file, unknown), chunkdoc.page_content, scorefloat(score), ) for doc, score in docs ] return AskResponse(answeranswer_text, sourcessources)返回sources是重要一步。它让用户能够点击查看原文一旦模型回答有偏差使用者可以对照来源自己判断。5. 运行验证与质量调优5.1 运行流程按顺序执行python ingest.py uvicorn app:app --host 0.0.0.0 --port 8000接着用 curl 测试curl -X POST http://localhost:8000/ask \ -H Content-Type: application/json \ -d {question: What is the highest thing according to the text?, top_k: 3}正常返回的 JSON 应该包含answer和sources。sources中应该能看到命中的文本片段以及它们来自哪个文件。5.2 评估回答质量不要只看一两个问题。建议准备至少 20 个测试问题分成三类直接引用类问题答案就在某一小节。语义理解类需要综合理解才回答。跨片段综合类需要结合多个片段。人工评估时记录四个维度评估维度说明来源命中检索到的片段是否真正相关忠实度回答是否基于给定片段而不是模型记忆完整性是否遗漏了片段中的重要信息幻觉率是否出现片段中不存在的内容根据评估结果反向调优检索和提示词而不是盲目换模型。5.3 参数调优参数影响建议chunk_size过小切断语义过大引入噪声400-800chunk_overlap减少边界切断10%-20%top_k越大信息越多噪声也可能越多3-5temperature越大越随机越小越稳定0.1 左右embedding 模型决定检索上限多语场景选 bge-m3如果发现检索结果经常不相关可以先检查 embedding 模型是否匹配再尝试给文本片段增加标题、章节等上下文前缀。比如把“section 002”拼到page_content前有助于模型区分不同片段。6. 常见问题排查6.1 检索不到相关片段现象问题明明在语料中但返回的片段全不相关或者分数极高。可能原因索引和查询使用了不同 embedding 模型。文本切分过小把关键词切散。问题语言与语料语言不一样且 embedding 模型不支持跨语言。检查方式打印vectorstore.embedding_function.model_name确认与 ingest 一致。直接对某个问题调用检索查看返回的相似度分数。尝试用原文中的部分单词作为查询词验证索引本身是否有内容。解决方式统一模型后重新生成索引针对多语言场景换用bge-m3如果必须支持中文提问英文语料可以先调用机器翻译把问题转成英文再检索。6.2 回答内容与原文不一致现象回答流畅但经对照来源片段后发现模型自己“脑补”了一段原文没有的内容。可能原因提示词约束不够模型没有严格遵守资料。top_k太小相关片段没有进入上下文。检索到了相关但不够精确的片段模型试图补充。检查方式查看返回的sources确认模型是否“看到了”正确的内容。把sources单独打印出来人工判断是否足以回答问题。解决方式在提示词中强化“请直接引用片段原文并说明来源”。提高top_k让更多候选进入上下文。引入 reranker在检索后对候选片段重新排序。6.3 接口响应非常慢现象一次/ask需要几十秒甚至更久。可能原因本地大模型生成速度慢。每次请求都重新加载模型。检索阶段没有采用索引检索而是暴力扫描全部向量。检查方式查看模型加载日志确认模型是否只加载一次。分阶段计时判断是检索慢还是生成慢。解决方式将模型封装在单例对象中进程启动时加载一次。生成服务单独部署并使用更大显存的 GPU。如果向量规模很大从 FAISS 迁移到 Milvus 或 Qdrant。6.4 FAISS 索引文件无法加载现象加载时出现allow_dangerous_deserialization相关错误。原因LangChain 对 pickle 反序列化加了保护开关。解决方式vectorstore FAISS.load_local( vectorstore, embeddings, allow_dangerous_deserializationTrue )但要强调这个开关只应在索引文件来自可信来源时打开。如果系统从不可信位置读取索引文件先做文件校验或重新生成。6.5 新增文档后检索结果没有更新现象修改了源文本重新执行ingest.py但接口返回的还是旧内容。可能原因向量库保存到了不同目录。服务进程没有重启仍然持有旧索引。ingest.py中未先删除旧索引文件。解决方式确认INDEX_DIR一致每次重建索引前删除旧目录发布新索引后重启服务。更稳妥的做法是使用带版本号的索引目录然后在配置中切换。7. 生产环境部署建议7.1 把索引构建做成数据管道不要每次修改文档后手动执行ingest.py。建议用 CI/CD 或定时任务处理流程是新文本进入源目录 - 清洗 - 切分 - 向量化 - 写入生产向量库 - 原子切换索引版本。索引版本建议保留至少两个当前版本和历史版本。这样如果新索引质量有问题可以快速回滚到旧版本。7.2 向量数据库选型FAISS 适合单机原型但生产环境通常需要多人同时访问和水平扩展。常见方案方案部署复杂度适合场景FAISS低单机、小规模、离线验证Chroma中中小规模、快速启动Qdrant中高可用、过滤条件复杂Milvus高大规模、高并发pgvector中已有 PostgreSQL 业务系统选择依据是数据量、查询 QPS 和团队运维能力。初期完全可以使用 FAISS等访问量上来后再迁移。7.3 API 安全与合规面向经典文本的问答应用重要的安全点不是文本本身而是防止用户诱导模型输出脱离原文的解读。建议请求参数校验限制问题长度。给 LLM 服务设置鉴权和限流。在提示词中继续强化“只能基于资料片段回答”。对输出做日志审计但不要记录不必要的个人数据。如果用户上传文件需要额外的病毒扫描和格式白名单。如果服务正式开放还要考虑数据版权。只使用你已经获得授权或公开可使用的语料版本。7.4 监控与告警线上至少关注四个指标接口响应时间检索时间和生成时间分别统计。错误率模型调用失败、数据库超时。相似度分布如果用户查询相似度普遍偏低说明语料覆盖不足或语言不匹配。来源命中率在对采样问题做评估时检查检索结果是否包含预期片段。监控需要简单可操作不要把告警做成“看板展示”而是要能触发处理。8. 扩展方向与最终建议8.1 多轮对话与追问可以加入对话记忆但要避免历史对话污染检索。更推荐的方式是只把“用户当前问题和最近一轮澄清”拼接到查询中而不是把整个聊天记录丢给检索器。8.2 引用溯源与前端展示在现有sources基础上可以让回答中的每个关键句对应一个原文卡片。前端点击卡片就能高亮原文片段。这是提升用户信任度的有效方式也是这类经典问答应用真正“可用”的必要条件。8.3 支持更多文档类型这套流程完全可以复用到其他领域。把page_001.txt换成合同条款、产品手册或内部制度文档清洗脚本和索引逻辑基本不需要改动。真正需要改动的是领域分词器、metadata 结构、提示词里的角色描述和评估集。8.4 给实践者的最后建议不要在一开始追求复杂的架构。先用本地小模型跑通最小闭环再围绕“检索质量”和“回答忠实度”做迭代。数据清洗、评估集构建和提示词调优对最终效果的影响通常会超过更换大模型的收益。如果你能完整跑通这篇文章里的示例后续遇到任何垂直领域问答需求都可以用同一套思路快速搭建并且逐步优化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价