尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

RAG实战:从文档加载到API封装的知识库问答系统

RAG实战:从文档加载到API封装的知识库问答系统 这次我们来看一个 RAG 检索增强生成问答系统的完整实战。重点不是重复“RAG 是什么”的概念而是把整条链路跑起来文档加载、中文分块、BM25 稀疏检索、稠密向量检索、RRF 倒数排名融合、Prompt 拼接、调用大模型生成答案最后封装成 API 和批量任务。整篇文章会带代码适合已经了解 RAG 基本概念、想亲手实现一个最小知识库问答系统的读者。这套方案的硬件门槛并不高。检索和融合环节主要吃 CPU 和内存不需要单独的大显存嵌入模型可以选小尺寸中文模型生成环节可以接本地大模型比如 Ollama 服务的 Qwen、DeepSeek也可以接 OpenAI 兼容接口。换句话说即使没有独立显卡把生成模型换成远端 API整套流程依然可以跑通。文章会绕开 LangChain 这种重量级框架先用最直观的方式把底层逻辑拆明白确认每一步都理解之后再考虑要不要迁移到成熟框架。最后给出批量问答和 FastAPI 接口封装方便直接接入真实业务。1. RAG 核心能力速览能力项说明项目类型RAG 检索增强生成问答系统实战技术梳理核心技术文档加载、文本分块、BM25 稀疏检索、稠密向量检索、RRF 倒数排名融合、LLM 生成主要依赖Python 3.9、jieba、rank_bm25、sentence-transformers、requests、FastAPI硬件门槛检索与融合部分 CPU 即可运行嵌入模型可选用小尺寸中文模型生成模型按参数规模选择本地 GPU 或远端 API支持平台Windows / Linux / macOS启动方式命令行脚本运行或通过 FastAPI 暴露 HTTP 服务是否支持 API支持可封装为/qa接口是否支持批量任务支持可批量文档入库、批量问题问答适合场景私有知识库问答、企业文档助手、RAG 学习实验、接口集成这里要强调一句RAG 项目没有固定公式不同场景对分块粒度、检索路数、融合策略、生成模型的要求都不一样。本文给的是最小可运行基线后面所有参数都可以按实际数据调整。2. RAG 整体架构与关键环节RAG 的完整流程可以拆成四个阶段数据准备、索引构建、检索召回、生成回答。下面这条链路是当前工业界比较通用的形态。知识库文档 - 加载解析 - 文本分块 - 向量化与索引构建 | 用户问题 - 查询短语处理 - 双路检索 --------| v RRF 融合排序 | Prompt 拼接 | 大模型生成 | 最终答案拆开看每个环节的职责文档加载把 PDF、Word、Markdown、TXT 等非结构化数据转成纯文本。这一步容易出问题的是 PDF 排版错乱、表格被拆散、页眉页脚混入正文。文本分块把长文档切成适合检索和模型输入的片段。分块太短会丢失上下文太长会引入噪声还容易超出模型上下文窗口。向量化与索引将文本片段转换成向量。这里有两种常见路线一种是稠密向量用深度模型把文本映射成固定维度向量另一种是稀疏向量用 BM25、TF-IDF 这类基于词频统计的方法。检索召回用户提问后从知识库中找到最相关的若干片段。融合排序多路检索结果合并最常用的方案之一就是 RRF 倒数排名融合。Prompt 拼接把检索片段和用户问题组织成结构化提示词。大模型生成将完整 Prompt 输入大模型输出答案。这个流程看着不算长但每一层都有参数和工程细节。下面从环境准备开始逐步实现。3. 环境准备与依赖安装3.1 创建虚拟环境建议使用独立虚拟环境避免项目依赖污染系统 Python。python -m venv rag-demo # Windows rag-demo\Scripts\activate # Linux / macOS source rag-demo/bin/activate3.2 安装 Python 依赖核心依赖包含分词的 jieba、BM25 实现的 rank_bm25、向量化编码的 sentence-transformers、数值计算的 numpy以及用于 API 调用的 requests。pip install jieba rank_bm25 sentence-transformers numpy requests如果还要做 API 服务再安装 FastAPI 相关依赖。pip install fastapi uvicorn pydantic如果涉及 PDF 解析额外安装 pypdf。pip install pypdf需要特别注意的是 sentence-transformers 会连带安装 PyTorch。如果你的机器没有配置好 CUDA建议先安装 CPU 版 PyTorch避免自动下载一个很大的 CUDA 依赖。pip install torch --index-url https://download.pytorch.org/whl/cpu pip install sentence-transformers有独立显卡且已装好 CUDA 的机器这一步可以跳过直接让 pip 自动匹配 GPU 版本。4. 文档加载与分块策略数据清洗和分块是 RAG 系统里最容易被低估的一环。很多人把时间花在调大模型接口上结果检索召回一堆废话问题往往就出在文档没有处理好。4.1 文本文件加载先用一个简单的文本读取函数做通用模板。真实项目中路径和编码需要按实际情况调整。def load_txt(path: str) - str: with open(path, r, encodingutf-8) as f: return f.read()4.2 PDF 文件加载PDF 解析比纯文本麻烦一些常见坑是扫描版 PDF 需要 OCR、表格被错误换行、多栏排版读取顺序错乱。先用 pypdf 做最基础的解析。from pypdf import PdfReader def load_pdf(path: str) - str: reader PdfReader(path) pages [] for page in reader.pages: text page.extract_text() if text: pages.append(text) return \n.join(pages)如果你的知识库主要是扫描件这个方案不够需要接 OCR 服务如果以 Word、HTML 为主可以使用 python-docx、BeautifulSoup 等工具。替换读取函数即可。4.3 中文文本分块分块策略直接影响检索效果。常见方案有三种。第一固定窗口分块简单粗暴但容易切断语义第二递归分隔符分块按标题、段落、句子的优先级逐级切分LangChain 的 RecursiveCharacterTextSplitter 就是这个思路第三语义分块用嵌入模型判断句子边界效果更好但代价更高。给出一个适合中文的轻量分块方案先按句号、问号、感叹号、分号切句再按最大长度合并同时保留重叠片段减少切分带来的信息丢失。import re def split_into_sentences(text: str): parts re.split(r(?[。]), text) return [p.strip() for p in parts if p.strip()] def split_into_chunks(text: str, chunk_size: int 300, overlap: int 50): sentences split_into_sentences(text) chunks [] buffer for sent in sentences: if len(buffer) len(sent) chunk_size: if buffer: chunks.append(buffer) if overlap 0: buffer buffer[-overlap:] sent else: buffer sent else: buffer sent if buffer: chunks.append(buffer) return chunks这里 chunk_size 和 overlap 都不是固定值。一般先用 200 到 500 字做实验看检索召回效果再调整。如果文档有明确的标题结构优先按标题层级切分再对每个章节做句子级切分效果通常会更好。5. 双路检索BM25 稀疏检索 稠密向量检索RAG 的检索阶段通常不会只依赖一条路。纯稠密向量检索在语义理解上很擅长但在精确匹配人名、编号、设备型号、特殊参数时表现不如关键词检索纯 BM25 又没有办法处理同义改写情况。所以更稳的方案是双路检索最后用融合算法合并结果。先准备一小批演示文档模拟一个知识库。documents [ RAGRetrieval-Augmented Generation检索增强生成通过在生成前检索外部知识库把相关内容作为上下文补充给大模型。, 稀疏向量检索的代表算法是 BM25。BM25 基于词频和逆文档频率对文档打分适合精确关键词匹配。, 稠密向量检索使用深度神经网络将文本映射为固定维度向量通过余弦相似度衡量语义相关性。, RRFReciprocal Rank Fusion倒数排名融合算法输入多路检索结果输出按融合分数排序的最终结果列表。, 常见 RAG 框架包括 LangChain、LlamaIndex、Dify 等。Dify 提供可视化工作流适合快速搭建知识库应用。, 中文文档分块建议优先按段落、标题、句子边界切分避免把一个完整语义单元拆散。, 大模型幻觉问题可以通过 RAG 引入外部事实来缓解前提是检索结果要准确、上下文要完整。, 向量数据库常见选型包括 FAISS、Milvus、Chroma、Qdrant。FAISS 轻量适合本地实验。, 构建 RAG 知识库时文档质量直接影响检索效果。清洗格式、去除无关页眉页脚是关键步骤。, 评估 RAG 系统通常关注检索召回率、上下文相关性、答案准确率和回答可溯源四个维度。, ]5.1 BM25 稀疏检索实现BM25 的核心思想是词在文档中出现得越多文档得分越高但这个词如果在整个文档集合中出现得越频繁它的权重就要下调。简单说既看重词频又惩罚普遍出现的词。代码使用 rank_bm25 库中文先做 jieba 分词。import jieba import numpy as np from rank_bm25 import BM25Okapi tokenized_docs [list(jieba.cut(doc)) for doc in documents] bm25 BM25Okapi(tokenized_docs) def sparse_search(query: str, top_k: int 3): query_tokens list(jieba.cut(query)) scores bm25.get_scores(query_tokens) top_indices np.argsort(scores)[::-1][:top_k] return [(int(idx), float(scores[idx])) for idx in top_indices]返回结果是不定长数组因为np.argsort(scores)[::-1]在 scores 全部相同时会给出从大到小的索引。top_indices转换成 int 后可以直接用于 documents 列表索引。5.2 稠密向量检索实现稠密向量这部分选一个对中文友好的小尺寸嵌入模型。BAAI/bge-small-zh-v1.5 是值得先试的模型体积小语义表现稳定。首次运行会自动下载模型文件下载时间取决于网络条件。from sentence_transformers import SentenceTransformer embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) doc_vecs embedder.encode(documents, normalize_embeddingsTrue) def dense_search(query: str, top_k: int 3): query_vec embedder.encode(query, normalize_embeddingsTrue) sims np.dot(doc_vecs, query_vec) top_indices np.argsort(sims)[::-1][:top_k] return [(int(idx), float(sims[idx])) for idx in top_indices]注意normalize_embeddingsTrue后向量点积就等于余弦相似度后续不需要再手动计算余弦公式直接 np.dot 即可。5.3 对比两种检索效果可以自己跑几组查询对比感受。查询“BM25 和稠密向量有什么区别”BM25 更容易命中带“BM25”“稠密向量”字样的片段稠密向量检索则可能召回“语义相关性”“向量化”相关的片段查询“如何评估 RAG 系统”BM25 可能因“评估”一词精准命中而稠密向量也能通过语义关联找到“评估 RAG 系统”相关片段。两条路的结果不完全一样这正是需要融合的原因。6. RRF 倒数排名融合算法原理与实现6.1 RRF 核心公式RRF 的全称是 Reciprocal Rank Fusion倒数排名融合。它的核心思想不依赖具体分数而是只依赖排名位置。公式如下。score(d) Σ 1 / ( k rank_i(d) )其中rank_i(d) 表示文档 d 在第 i 路检索结果中的排名从 0 开始计k 是平滑常数原论文中通常取 60Σ 表示对多路检索结果求和。使用排名而不是原始分值好处很明显不同检索器输出的分数尺度可能完全不同向量相似度可能是 0.6 到 0.9BM25 分数可能是几到几十直接加权平均很不公平。RRF 把每路结果统一成“第几名”用排名参与计算天然规避了分数尺度不一致的问题。6.2 RRF 代码实现实现非常短。def rrf_fusion(ranked_lists, k: int 60): fused {} for ranked in ranked_lists: for rank, doc_id in enumerate(ranked): fused[doc_id] fused.get(doc_id, 0) 1.0 / (k rank 1) return sorted(fused.items(), keylambda x: x[1], reverseTrue)enumerate(ranked)从 0 开始所以分母用k rank 1。如果某个文档只在其中一路出现它只获得这一路的分数两路都出现且排名靠前的文档融合分数会明显更高。6.3 融合效果验证用一个例子手动算一遍。假设某文档 A 在稀疏检索中排第 1 名在稠密检索中排第 3 名k 取 60。A 的融合分数 1/(601) 1/(603) 0.01639 0.01587 0.03226另一篇文档 B 在稀疏检索中排第 2 名在稠密检索中排第 1 名。B 的融合分数 1/(602) 1/(601) 0.01613 0.01639 0.03252可以看到 B 的融合分数略高因为它拿到了一个“第 1 名”和一个“第 2 名”整体排名质量稍好。把双路检索接在一起def hybrid_search(query: str, top_k: int 3): sparse_top sparse_search(query, top_k5) dense_top dense_search(query, top_k5) sparse_ids [doc_id for doc_id, _ in sparse_top] dense_ids [doc_id for doc_id, _ in dense_top] fused rrf_fusion([sparse_ids, dense_ids]) return fused[:top_k]这里检索时先各取 5 条再融合取前 3 条。之所以多取一些再截断是因为融合阶段有时会出现“单路排名第 6 但两路都出现”的文档综合排序可能比“单路第 3”更靠前。7. 大模型生成与完整问答流程检索到相关片段后下一步就是组装 Prompt 并调用大模型。7.1 Prompt 设计RAG 的 Prompt 设计核心是三点明确角色、提供资料、限制编造。SYSTEM_PROMPT 你是一个严谨的知识库问答助手。请根据提供的资料回答问题。如果资料中没有相关信息直接说不知道不要编造。 def build_prompt(question: str, context_chunks) - str: context \n\n.join( [f[片段{i1}] {documents[doc_id]} for i, (doc_id, _) in enumerate(context_chunks)] ) return f资料\n{context}\n\n问题{question}\n\n请基于资料回答context_chunks是hybrid_search返回的结果每个元素是(doc_id, score)元组。这里把文档原文拼进 Prompt并保留片段编号方便后续做答案溯源。7.2 调用大模型大模型接入方式有很多种。本地推荐用 Ollama它启动后提供 OpenAI 兼容接口。这里以 Ollama 为例模型名需要按本机实际拉取的模型替换。import requests LLM_BASE_URL http://127.0.0.1:11434/v1 LLM_MODEL qwen2.5:7b-instruct LLM_API_KEY ollama def generate_answer(question: str, context_chunks, temperature: float 0.3) - str: prompt build_prompt(question, context_chunks) resp requests.post( f{LLM_BASE_URL}/chat/completions, headers{Authorization: fBearer {LLM_API_KEY}}, json{ model: LLM_MODEL, messages: [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: prompt}, ], temperature: temperature, max_tokens: 512, }, timeout120, ) resp.raise_for_status() data resp.json() return data[choices][0][message][content]如果本机 Ollama 版本不支持 OpenAI 兼容端点也可以调用原生/api/chat接口把LLM_BASE_URL换成http://127.0.0.1:11434/api/chat参数格式略有不同。接云端大模型时只需要改成对应的接口地址、密钥和模型名。7.3 完整问答脚本把前面的临时环境变量和函数整合成一个单文件脚本方便跑通全流程。这个脚本只是演示正式项目里建议把索引构建和查询服务拆成两个模块。import jieba import numpy as np import requests from rank_bm25 import BM25Okapi from sentence_transformers import SentenceTransformer documents [...] # 上一节定义的演示文档 # BM25 索引 tokenized_docs [list(jieba.cut(doc)) for doc in documents] bm25 BM25Okapi(tokenized_docs) # 稠密向量索引 embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) doc_vecs embedder.encode(documents, normalize_embeddingsTrue) def sparse_search(query, top_k5): tokens list(jieba.cut(query)) scores bm25.get_scores(t
返回列表