拓冰建站拓冰建站
首页 / 资讯中心 / 正文

RAG实战指南:基于大模型与向量数据库构建企业知识库问答系统

1. 项目概述llm_wiki到底是个什么项目先说结论llm_wiki 是一个典型的 RAGRetrieval-Augmented Generation项目核心思路是“用大语言模型聊自己的知识库”。说白了就是不把文档喂给模型训练而是先把文档拆好、存好、建好索引等用户提问的时候先从知识库里捞出最相关的内容再把这些内容连同问题一起交给大模型生成答案。现在这类项目在社区里特别火GitHub 上相关开源项目一抓一大把但真正把它做成一个顺手、可用、能落地的系统里面门道其实不少。我做这个项目的主要动机很实际团队内部积累了大量的技术文档、设计文档、会议纪要分散在飞书、语雀、本地 Markdown 文件里。搜索要么搜不到要么搜出来一堆过时内容。用传统的关键词搜索只能做到“字面匹配”搜“用户登录超时怎么排查”如果你的文档里写的是“认证接口响应缓慢”那就根本匹配不上。llm_wiki 要解决的正是这类语义检索问题——让文档能被“理解”而不是只被“匹配”。这个项目适合谁参考如果你手头也有几百篇文档需要做成智能问答或者你想给个人笔记库加一个 AI 对话入口再或者你是刚接触 RAG 生态想找一个完整落地案例的开发者这篇文应该都能帮到你。我会从整体架构、各个核心模块的参数选型、完整的实操过程、以及我在做这个项目时踩过的坑和排查方法全部拆开了讲。2. 整体设计与技术选型为什么是RAG而不是微调2.1 先泼冷水微调不是你以为的那样很多刚接触这个方向的人第一个念头就是“我要微调一个自己的模型”。我做过几次微调负责任地说如果你只是想让自己文档里的知识能被回答微调在绝大多数场景下是错误的方向。原因有三点微调的本质是改变模型的行为模式和知识边界它适合的是“让模型学会某种格式的输出”“让模型掌握某个领域的表达风格”而不是“记住你文档里的某个具体数字”。文档内容每天都在变今天微调完明天文档改了你不可能天天重新微调。微调成本不低一张 A100 或者多张消费级显卡的租用费用对个人和小团队来说都是不小的开销。更别提数据清洗、构造训练样本这些前置工作分分钟让人崩溃。RAG 的思路完全不同。文档是外部数据库模型永远是那个模型回答的时候临时去数据库里取内容。文档变了就重新更新索引模型不需要动。2.2 方案选型从 LangChain 到 LlamaIndex 到自研我当时对比了主流的三个技术路线LangChain 向量数据库、LlamaIndex、以及自己写一套编排逻辑。LangChain 生态最丰富但说实话它的抽象层级太多出问题的时候排查起来特别痛苦。你问它一个问题中间隔了 Chain、Retriever、Embedding、PromptTemplate 好几层封装每层都可能出错日志又不够直观。LlamaIndex 在处理“文档索引”这件事上更专注API 设计也更贴合知识库场景。但它和 LangChain 一样都存在一个问题版本升级频繁接口变动大网上教程大量过时。我最终的方案是核心推理链路自己写基础组件用成熟的库。文档解析用 unstructured向量化用 OpenAI 的 text-embedding-3-small 或者开源的 BGE 系列向量存储用 Milvus大模型调用支持 OpenAI 兼容接口这样后续可以无缝切换国产模型或本地部署模型。编排层不需要框架几十行代码就能把“检索→拼接→生成”的流程写清楚出了问题一眼就能定位。提示不是反对用框架而是建议你先把核心链路手写一遍跑通了再考虑引入框架来省事。手写一遍之后你会对 RAG 每一步在做什么有非常清晰的认识排查问题也会更有底。2.3 架构设计四个核心模块一张图llm_wiki 整体分四个模块各管一段模块职责核心组件文档解析层把 PDF、Markdown、Word、HTML 转成纯文本unstructured、PyMuPDF索引构建层分块、向量化、写入向量库LangChain 的 TextSplitter只用了分割器、BGE-M3 embedding检索层根据用户问题召回相关文档片段Milvus 向量检索 BM25 关键词检索生成层组装提示词调用大模型生成答案OpenAI 兼容接口支持 temperature 等参数调节我把 mermaid 图省了直接画成表格你应该也能看懂。整个流程串起来就是用户提问 → 向量化这个问题 → 从 Milvus 里查相似片段 → 同时用 BM25 做关键词检索 → 两个结果合并去重 → 按相关性排序取 TopK → 拼进 Prompt → 交给大模型 → 输出答案并附上引用来源。这里的关键决策是混合检索。我一开始只用了纯向量检索结果发现一些包含特定名词、型号、代码片段的问题召回效果很差。因为向量检索擅长语义相似但遇到精确匹配的场景比如你文档里有个型号叫“ABC-2000”用户问的时候也是“ABC-2000”向量检索反而可能因为语义干扰而漏掉精确匹配的结果。加上 BM25 做字面匹配互补效果立竿见影。3. 核心细节解析分块、向量化、检索与重排3.1 分块策略chunk_size 和 chunk_overlap 不是越大越好分块是整个 RAG 链路里最容易被低估的环节。很多人随便设一个 chunk_size500 就跑结果检索效果差得想骂人。我调了两周分块参数把经验总结成一句话分块大小取决于你的文档结构和预期问题的答案粒度。什么是答案粒度举个例子你的文档是产品操作手册用户问“如何重置密码”答案大概是一个步骤列表约 200 字那你的 chunk 在 300-500 字是合理的。如果你的文档是周报汇总用户问“上周销售数据怎么样”答案可能需要跨多个段落综合那 chunk 就得大一些或者依赖多路召回再聚合。我在 llm_wiki 里对不同来源的文档设置了不同的分块策略Markdown 技术文档按标题层级切分优先保证一个章节的内容完整性。用 MarkdownHeaderTextSplitter以 #、##、### 作为分割边界再对超出 800 token 的大节做二次切分。PDF 论文/报告固定分块chunk_size512chunk_overlap80因为 PDF 提取后的文本段落边界往往不可靠。代码文件按函数和类定义切分chunk_size300overlap30因为代码的语义边界比自然语言更清晰。chunk_overlap 的作用是避免“语义切断”。比如一句话从中间断开了前半截在 chunk A后半截在 chunk B如果没有任何重叠那两段都是残缺的检索质量直接下降。overlap 设 10%-15% 是经验值太小没用太大浪费 token。3.2 向量化模型选型BGE-M3 还是 OpenAI Embedding向量化就是把文本变成一串浮点数让“意思相近”的文本在向量空间里离得近。我对比了好几个方案OpenAI text-embedding-3-small效果确实好维度 1536费用很低百万 token 才几毛钱而且支持 Matryoshka 降维对中文英文都友好。缺点就是数据要出网如果你对隐私有要求就不能用。BGE-M3智源开源支持 100 语言中文效果很棒而且它同时支持稠密检索、稀疏检索和多向量检索三种方式可以配合 BM25 做混合检索。显存占用也不大4G 就够跑。m3e / text2vec 之类的小模型轻量但效果差距明显建议直接跳过。最终我选择了 BGE-M3 作为主力模型本地跑不依赖外部 API数据不用出网。一个重要的调试经验向量模型必须和检索场景匹配。BGE 系列的模型在计算相似度时官方建议查询和文档两侧都做指令前缀query 侧加 “为这个句子生成表示以用于检索相关文章”不加这个前缀检索效果能下降好几个点。刚踩这个坑的时候百思不得其解后来翻官方文档才找到说明。3.3 向量数据库Milvus、Qdrant、Chroma 怎么选向量数据库这个环节我见过太多人选型的纠结了。直接说结论按场景分个人笔记库、几百个文档的量级Chroma 就够了pip install chromadb一个命令搞定数据存在本地文件里。团队知识库、十万级文档、需要高并发Milvus 或者 Qdrant二者差别不大。Milvus 在分布式和运维生态上更成熟Qdrant 的 API 设计更简洁、Rust 写的性能很好。我最后用的 Milvus理由很实在可以直接用 Milvus Lite 在本地跑等功能攒够了再无缝切到分布式集群Milvus Standalone 或 Milvus Cluster不用改业务代码。Milvus 的 collection集合设计对应关系型数据库的“表”每个 Collection 需要声明向量维度必须和你的 embedding 模型输出维度一致。BGE-M3 输出 1024 维那就得建一个 1024 维的 Collection。3.4 检索与重排混合检索 Rerank 才是完整方案检索环节我做了两路召回合并之后还有一步重排。向量检索查的是语义相近BM25 查的是词面重合。在 Milvus 里可以直接建两个 index——一个 HNSW 向量索引一个 Scalar 字段索引配合 BM25 函数Milvus 2.4 之后内置了 BM25 支持也可以用外部工具比如 Elasticsearch做 BM25。两路结果合并之后直接用 RRFReciprocal Rank Fusion算法做简单融合。RRF 的公式很简单对每个文档计算 sum(1/(k rank_i))其中 rank_i 是该文档在第 i 路结果中的排名k 一般取 60。这个算法不需要调权重鲁棒性好效果却出奇地好。融合之后的排序还是“粗排”真要追求效果最后加一个 Rerank 模型。我用的 BGE-reranker-base它和 BGE-M3 搭配效果好cross-encoder 架构把 query 和候选文档拼在一起输入模型输出一个相关性分数。因为要对所有候选都过一遍模型速度慢所以只对 Top50 的结果重排取 Top5 进 Prompt。实测下来加了 rerank 之后答案准确率提升非常明显特别是候选文档里存在“看着相似但实质无关”的内容时。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先说环境我用的是 Ubuntu 20.04 Python 3.10 一张 24G 显存的 3090需要本地跑 BGE-M3 和 rerank显存 8G 其实也够4G 勉强也能跑就是慢。整机 64G 内存文档量级是几万篇这个配置跑起来非常宽裕。依赖包列表pip install pymilvus2.4.9 # 连接 Milvus 2.4当前稳定版 pip install FlagEmbedding # 包含 BGE-M3 和 BGE-reranker pip install unstructured[pdf,docx] # 文档解析带 PDF 和 Word 解析依赖 pip install langchain-text-splitters # 只用它的文本分割器 pip install jieba # 中文分词BM25 预处理用 pip install openai # 调用大模型 API然后启动 Milvus Lite本地文件模式零配置from pymilvus import MilvusClient client MilvusClient(./data/milvus.db)对你只需要这一行。Milvus 2.4 之后的 Lite 模式就是这么简单数据直接落在本地文件里。不过要知道Lite 模式只适合开发和测试生产环境还是得用 Standalone 或 Cluster。4.2 文档解析从 PDF 到干净文本的坑unstructured 这个库支持 PDF、Word、HTML、Markdown 等格式但解析效果并不能完全信任。尤其是 PDF很多是从网页直接导出的里面的文本块顺序可能是乱的表格可能被拆得七零八落。我加了两层处理第一层用 unstructured 按元素类型分块。它不是把整个 PDF 变成一个字符串而是解析成一个个元素Title、NarrativeText、Table、ListItem每个元素带着自己的类型和位置信息。这样后续分块可以尽量让同一类型的内容在一起表格不会被一句话切断。第二层对表格单独处理。因为 RAG 对表格的检索效果普遍不好把表格变成 Markdown 格式再入库比把表格转成纯文本要可靠得多。大模型看到 Markdown 格式的表格能理解结构而纯文本的表格内容就完全丢失了对应关系。核心代码示例from unstructured.partition.pdf import partition_pdf def parse_pdf(file_path): elements partition_pdf( file_path, strategyhi_res, # 高精度模式会做OCR处理扫描件 infer_table_structureTrue ) chunks [] for el in elements: if el.category Title: chunks.append({text: str(el), type: title}) elif el.category Table: chunks.append({text: el.metadata.text_as_html, type: table}) else: chunks.append({text: str(el), type: body}) return chunks注意strategyhi_res 走的是 OCR 识别模型速度慢、消耗资源大但遇到扫描版 PDF 是真的能救命。如果确定你的 PDF 都是文字版用 strategyauto 就够了速度快一个量级。4.3 分块切分Markdown 结构保留还是纯文本切这里有一个体验差异很大的细节。如果你直接对 Markdown 全文做固定尺寸切块那么代码块、表格、列表都可能被拦腰截断而且切出来的 chunk 丢了标题信息后续检索到它也不知道它在文档的哪个章节回答的时候没法给出引用来源。llm_wiki 的处理方式是两段式分块第一段按 Markdown 标题切大块。把一篇文章按一级标题、二级标题切成若干个 section。第二段对每个 section 内部再做固定大小切分如果它超过了 max_chunk_size。这样每个 chunk 都带着它所属的标题路径比如 “/产品指南/安装部署/环境要求”。检索到内容之后很容易追溯来源而且分块不会跨越章节边界。我用的是 langchain-text-splitters 里的 MarkdownHeaderTextSplitter配合 RecursiveCharacterTextSplitter 做二次切分from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter headers_to_split_on [ (#, H1), (##, H2), (###, H3), ] md_splitter MarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on) recursive_splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap80, separators[\n\n, \n, 。, , , , ], )separators 这个参数要注意分割优先级是从左到右。它会让模型先尝试按双换行切切出来还是太长就按单换行再太长就按句号、分号、逗号。中文文档能把 separators 配置成中文标点效果比用英文默认值好很多。4.4 向量化与写入 Milvus批量入库的正确姿势向量化这一层我直接用 FlagEmbedding 的 BGEM3FlagModelfrom FlagEmbedding import BGEM3FlagModel model BGEM3FlagModel(BAAI/bge-m3, use_fp16True) def embed_batch(texts): output model.encode( texts, return_denseTrue, return_sparseTrue, # 输出稀疏向量可用于混合检索 max_length1024 ) return output[dense_vecs]这里需要注意 max_length 参数。BGE-M3 的默认 max_length 是 8192但实际分块后文本一般不超过 512 token设置成 1024 就够了过长反而会拖慢编码速度还浪费显存。批量写入 Milvus 要控制 batch size。我实测下来embedding 之后一次性往 Milvus 灌 256 条是比较稳妥的太多了容易超时太少了吞吐上不来。from pymilvus import MilvusClient client MilvusClient(./data/milvus.db) # 建 Collectionembedding dim 为 1024BM25 需要开一个 SPARSE_INVERTED_INDEX client.create_collection( collection_namewiki_docs, dimension1024, metric_typeCOSINE, primary_field_nameid, vector_field_namedense_vector, sparse_field_namesparse_vector, ) for i in range(0, len(chunks), 256): batch chunks[i:i256] data [ { id: chunk[id], text: chunk[text], source: chunk[file_path], title_path: chunk[title_path], dense_vector: embed_batch([chunk[text] for chunk in batch])[j], sparse_vector: compute_sparse_embedding(batch[j]), } for j, chunk in enumerate(batch) ] client.insert(collection_namewiki_docs, datadata)录入之后要 flush再建索引。不建索引直接查是很慢的。client.create_index( collection_namewiki_docs, index_params{ field_name: dense_vector, index_type: HNSW, metric_type: COSINE, params: {M: 16, efConstruction: 200}, }, )HNSW 的两个参数 M 和 efConstructionM 是图的最大邻居数越大召回越准但内存占用越高16 算均衡值efConstruction 控制建索引时的搜索范围200 是官方推荐的起点。4.5 混合检索 RRF 重排 大模型生成完整链路检索这一步用 Milvus 的 hybrid_search API把 dense 和 sparse 两路请求同时发出去def hybrid_search(query, top_k50): dense_vec embed_text(query) res client.hybrid_search( collection_namewiki_docs, reqs[ {vector: dense_vec, sparse: sparse_vec, limit: top_k}, ], rank_params{strategy: rrf, params: {k: 60}}, output_fields[text, source, title_path], ) return res[0]RRF 合并之后拿 Top50 条结果进入 rerankfrom FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True) def rerank(query, documents, top_n5): pairs [[query, doc[text]] for doc in documents] scores reranker.compute_score(pairs, normalizeTrue) sorted_results sorted(zip(documents, scores), keylambda x: x[1], reverseTrue) return sorted_results[:top_n]然后把 Top5 的原文内容拼进 Prompt最后交给大模型def generate_answer(question, contexts): context_text \n\n---\n\n.join( [f[来源: {doc[source]}]\n{doc[text]} for doc, _ in contexts] ) prompt f你是企业知识库助手请基于以下文档内容回答问题。 如果文档内容不足以回答问题请诚实说明根据当前知识库内容无法确认。 请用简洁、准确的语言回答并标注回答中每条信息对应的来源编号。 文档内容 {context_text} 问题{question} 回答 response client.chat.completions.create( modelgpt-4o-mini, messages[{role: user, content: prompt}], temperature0.3, ) return response.choices[0].message.content, contextstemperature 设置成 0.3 是我反复测出来的。太高了回答天马行空太低了又显得机械0.3 在知识问答场景下兼顾了准确性和自然度。5. 常见问题与排查技巧实录5.1 检索不到内容先确认不是分块的锅这是最最常见的问题。用户问了个问题结果搜索为空或者召回的文档相关度惨不忍睹。我的排查顺序是第一步确认问题本身能否被检索到。直接在向量库里搜原始文档的原文片段看能不能搜到。搜不到说明问题出在 embedding 或者索引搜得到说明问题在 query 的改写或分块。第二步检查 query 的 embedding 是否对齐。BGE 系列要加查询指令前缀不加前缀效果掉几个点。这个我在前面提过很多自称“调好了”的项目其实根本没注意到这一点。第三步检查是否走了正确的 collection。Milvus 不区分名字如果你之前建过旧的 collection 忘了删重新插入时可能进了旧表查的时候又去了新表。第四步检查分块是否切碎了关键语义。比如一个文档里反复出现产品名“KM-500”如果你用固定 500 字切块恰好把提到 KM-500 的那个段落切成了两半检索质量就会受损。这种情况可以用关键词感知的分块——检测文本里的产品名、术语尽量让一个 chunk 覆盖完整术语。5.2 答案胡编乱造RAG 的幻觉问题比你想的更隐蔽RAG 能大幅缓解幻觉但不能完全消除。我遇到过一个典型场景文档里根本没有用户问题的答案但检索结果里有一些词面相似的内容大模型就顺着这些碎片自己编了一通。这个问题非常难用调 Prompt 来解决。我的解法是组合拳第一在 Prompt 里明确要求“如果文档内容无法回答问题请直接说无法确认”。这句指令看着简单实际上能让幻觉率下降不少。第二对 rerank 的分数设置阈值。每篇文档经过 rerank 之后会有一个相关性分数如果 Top1 的分数都低于 0.5BGE-reranker 归一化之后那基本可以判定知识库里没有相关内容这时候直接返回“未找到相关内容”不喂给大模型。第三强制让大模型在回答时引用来源编号没有来源支撑的句子不允许输出。这一步能从机制上逼着模型“只看文档说话”。5.3 查询太慢瓶颈到底在哪里llm_wiki 早期查询要 3-4 秒分了三处才发现瓶颈embedding 模型是 CPU 跑的一个 query 要 200ms。换到 GPU 之后降到 10ms。rerank Top50 的 cross-encoder 推理是最大的耗时点50 条排序要 1.5 秒。解决方案是把 rerank 从 Top50 改成 Top30然后换用更小的 bge-reranker-base。Milvus 查询本身很快HNSW 索引下百毫秒内搞定。整体优化之后查询耗时从 4 秒降到 800ms 左右体感上算是“能用了”。但要注意的是RAG 链路天生有多个环节中间任何一个环节变慢都会拖垮整体。5.4 文档更新后索引不同步增量更新的实现知识库是活的文档天天变。我开始的做法是每次全量重建索引文档少的时候还行文档多了每次要跑十几分钟那就不能忍了。后来实现了一个简单的增量更新机制维护一个文件哈希表文档修改日期或者内容哈希变了就把这条文档重新解析、重新分块、重新向量化并删除旧的向量记录。import hashlib, json, os INDEX_META_FILE index_meta.json def load_meta(): if os.path.exists(INDEX_META_FILE): with open(INDEX_META_FILE) as f: return json.load(f) return {} def save_meta(meta): with open(INDEX_META_FILE, w) as f: json.dump(meta, f, ensure_asciiFalse, indent2) def file_hash(path): with open(path, rb) as f: return hashlib.md5(f.read()).hexdigest() def sync_document(file_path): meta load_meta() new_hash file_hash(file_path) if meta.get(file_path) new_hash: print(f未变化: {file_path}) return # 删除旧的 region再解析、分块、入库 # ... meta[file_path] new_hash save_meta(meta)这个机制跑起来之后我只需要在每日定时任务里对全部文档目录执行一遍 sync_document遍历成本忽略不计就是比对哈希有变化的文件才会真正触发重新索引。5.5 中文路径与乱码几个绕不开的坑做个中文知识库一定会遇到编码相关的坑。我碰到的几个典型问题某些 PDF 从 macOS 导出的unstructured 解析出来局部乱码。这种情况可以换 PyMuPDF 单独解析或者对特定 PDF 用 OCR 方式兜底。Milvus 的 scalar 字段存储 source 路径时要注意统一编码文件路径里带 Unicode 字符时查回来在终端显示会乱码但实际上存储是没问题的别被显示问题误导。分词环节把“登录”切成了“登 录”或“录 登”会让 BM25 召回出现噪声。我的做法是固定维护一个自定义词典文件把所有产品名、团队内部黑话都加进去让 jieba 优先按词典切分。6. 后续扩展与我的经验总结llm_wiki 目前已经跑了大半年服务内部三个团队文档总量超过 5 万篇。整体效果和可维护性都比最开始全量重建索引的方案好了很多。我后续的计划是在两个方向继续扩展。第一个方向是多轮对话能力。现在的实现是“单轮问答”用户问一个问题系统检索一次、回答一次。但业务同学在实际使用中经常需要追问“那具体怎么做呢”“这个方案的成本是多少”这些追问都依赖前面的上下文。要做多轮对话就要把历史上的对话内容也纳入检索范围在检索时加上前几轮的问题和回答这对检索策略又提出了新的挑战。第二个方向是用户反馈闭环。我在生成回答的接口里埋了两个按钮——有用/没用。用户点击“没用”时记录当时的 query、检索出的文档、模型生成的回答。定期捞出来看哪些问题总是被回答烂再针对性地优化分块策略和 Prompt 提示词。这个反馈闭环是最便宜的迭代手段比盲目调参值钱多了。最后再分享一个判断标准RAG 系统做得好不好不是看回答得多流畅而是看引用得准不准。如果回答里说“根据文档某某接口的限流阈值是 1000 QPS”你得能溯源到具体是哪一篇文档、哪一个段落提供了这个信息。做不到这一点前面所有环节配置得再精美本质上都是花架子。这就是我做 llm_wiki 的全部实践经验了。从最初每天折腾分块参数到后面逐步把混合检索、rerank、增量更新逐个落地每一步的收益都看得见摸得着。如果你也在做类似的知识库问答项目按照这篇文章的链路走一遍应该能少踩不少我踩过的坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门