★
半年前我接了一个内部知识库的需求,要求很简单:「把我们的文档喂给 AI,让它能回答用户的问题」。我当时觉得这不就是 RAG 嘛,两天搞定。
结果我花了整整三周,才让它勉强能用。
这篇文章记录的,就是这三周里我被折磨出来的经验。
一、先把原理讲透,再谈为什么会出问题
很多教程上来就贴代码,但如果你不理解每一步在做什么,代码跑通了你也不知道结果为什么不对,更不知道从哪里下手优化。
RAG 的本质是三件事:找到相关内容、把内容给 AI、让 AI 基于内容回答。
用更具体的语言描述:
① 建库阶段(离线) 把文档切成小段(Chunk) → 每段文字转成一串数字(向量 / Embedding) → 存进向量数据库② 检索阶段(在线) 用户提问 → 问题也转成向量 → 在数据库里找"数字最接近"的那些段落 → 取出 Top-K 段原文③ 生成阶段(在线) 把检索到的段落 + 用户问题拼成 Prompt → 送给 LLM 生成最终回答听起来很直观对吧?问题就藏在每一个箭头里。
二、坑一:Chunk 切得像乱刀斩乱麻
最开始怎么写的
刚开始图省事,直接按固定字数切,500 字一刀,干净利落:
def naive_chunk(text, chunk_size=500): return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]发生了什么
有个文档写的是:
★
“……综上所述,该方案存在三点主要风险。第一,资金链断裂风险;第二,供应商集中风险;第三……”
切完之后,第一段最后是「第三」,第二段开头是「合规风险,具体表现为……」
用户问「有哪些风险」,检索到第一段,AI 告诉用户「只有两点风险」。
为什么会这样
固定字数切割完全不看内容语义,只认字数。一个完整的知识点很可能被切成两半,检索时只能拿到半截,LLM 当然给出残缺的答案。
怎么解决
改成递归语义切割,优先按段落、句子等自然边界切,同时加上重叠窗口,让相邻 Chunk 之间共享一段内容,防止上下文在边界处断裂:
from langchain.text_splitter import RecursiveCharacterTextSplittersplitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, # 相邻 Chunk 重叠 80 字,保住边界上下文 separators=[ "\n\n", # 优先按空行(段落)切 "\n", # 其次按换行切 "。", "!", "?", # 再按句子结尾切 " ", "" # 最后才按字切 ])chunks = splitter.split_text(text)经验值:chunk_overlap设成chunk_size的 15%~20% 比较稳。重叠太小上下文断裂,重叠太大引入冗余,干扰检索排名。
三、坑二:Embedding 模型选错,中文像在说火星话
最开始怎么写的
从一篇英文教程直接复制过来的代码,用的是text-embedding-ada-002:
from openai import OpenAIclient = OpenAI()def embed(text: str) -> list[float]: return client.embeddings.create( input=text, model="text-embedding-ada-002" ).data[0].embedding发生了什么
用户问「如何申请年假」,文档里明明有一整节「年假申请流程」,检索结果里没有它,反而出来一堆不相关的段落。
代码没有任何报错,跑得很顺,但结果就是不对。这是最隐蔽的一类坑。
为什么会这样
ada-002以英文语料为主训练,对中文语义理解相当有限。它不太能理解「申请年假」和「年假申请流程」在语义上高度相关。
怎么解决
中文文档必须换中文友好的 Embedding 模型:
| 模型 | 特点 | 推荐场景 |
|---|---|---|
BAAI/bge-m3 | 开源、中文效果最强 | 优先考虑,本地或云端均可 |
text-embedding-3-large | OpenAI 新版,多语言大幅提升 | 不想本地部署、预算充足 |
moka-ai/m3e-base | 轻量,中文够用 | 内存资源紧张时 |
换成bge-m3之后,「年假」那个例子直接从检索不到变成排名第一:
from sentence_transformers import SentenceTransformer# 第一次运行会自动下载模型,约 2GBmodel = SentenceTransformer("BAAI/bge-m3")def embed(texts: list[str]) -> list[list[float]]: # normalize_embeddings=True 后可直接用点积计算余弦相似度 return model.encode(texts, normalize_embeddings=True).tolist()Embedding 模型是 RAG 效果的地基,地基没打好,后面再怎么优化都是在沙滩上盖楼。换模型这件事,越早做越好。
四、坑三:向量检索不认数字和专有名词
发生了什么
财务同事问:「2024 年第三季度的净利润是多少?」
文档里这个数字明明白白写着,但检索出来的是一堆「公司财务状况概述」和「利润分配原则」,就是没有那个具体的季度数据。
为什么会这样
向量检索的原理是语义相似度,它擅长理解「意思差不多」的表达,但不擅长精确匹配。
「2024 年第三季度净利润」这种查询,语义层面和很多财务类文本都沾边,但「2024」「第三季度」这些精确信息,反而被语义的海洋淹没了。这个问题有个名字叫词汇鸿沟(Lexical Gap)。
怎么解决
混合检索(Hybrid Search):向量检索负责语义,BM25 关键词检索负责精确匹配,两个结果加权融合:
from rank_bm25 import BM25Okapiimport numpy as npclass HybridRetriever: def __init__(self, chunks: list[str], embedder): self.chunks = chunks self.embedder = embedder self.bm25 = BM25Okapi([c.split() for c in chunks]) self.vectors = np.array(embedder(chunks)) def retrieve(self, query: str, top_k: int = 8, alpha: float = 0.5): """ alpha: 向量检索权重,(1 - alpha) 为 BM25 权重 alpha 越大 → 越偏语义;alpha 越小 → 越偏精确匹配 """ q_vec = np.array(self.embedder([query])[0]) vec_scores = self.vectors @ q_vec bm25_scores = np.array(self.bm25.get_scores(query.split())) def normalize(arr): mn, mx = arr.min(), arr.max() return (arr - mn) / (mx - mn + 1e-9) combined = alpha * normalize(vec_scores) + (1 - alpha) * normalize(bm25_scores) top_idx = combined.argsort()[::-1][:top_k] return [self.chunks[i] for i in top_idx]调参建议:
- 对话问答类场景:
alpha=0.6,偏语义 - 财务报告、技术文档等精确查询多的场景:
alpha=0.3,让 BM25 更强势 - 不确定时从
0.5起步,跑几条测试用例再调
五、坑四:Top-K 设太大,LLM 被噪音淹没
发生了什么
为了「保险」,我把top_k设成 10,把 10 段内容全塞进 Prompt。
然后 LLM 开始把不相关的内容混进答案,把某段「行业背景介绍」当成依据,给出了完全错误的回答,还给得理直气壮。
为什么会这样
LLM 不是搜索引擎,它不会「忽略」不相关的内容,而是尝试用所有给它的内容来生成答案。
这个现象有个研究名字叫Lost in the Middle:LLM 倾向于重点关注 Prompt 开头和结尾的内容,中间的内容容易被混淆。塞的内容越多,相关信号被稀释越严重。
怎么解决
在检索和生成之间加一层Rerank(重排序):先粗检索一批候选,再用专门的重排序模型精准打分,只保留真正相关的 Top-N:
from sentence_transformers import CrossEncoderreranker = CrossEncoder("BAAI/bge-reranker-v2-m3")def rerank(query: str, candidates: list[str], top_n: int = 3) -> list[str]: # CrossEncoder 对每个 (query, candidate) 对单独打分 # 比向量相似度更精准,但速度更慢,所以只用于精排阶段 scores = reranker.predict([(query, c) for c in candidates]) ranked = sorted(zip(scores, candidates), key=lambda x: x[0], reverse=True) return [doc for _, doc in ranked[:top_n]]# 完整流程:粗检索 → 精排raw_candidates = retriever.retrieve(query, top_k=10) # 粗检索 10 个final_context = rerank(query, raw_candidates, top_n=3) # 精排保留 3 个这是性价比最高的单点优化,接入成本低,效果立竿见影。粗检索 8~12、精排保留 3~5,这个区间大多数场景下都稳。
六、坑五:用户提问太模糊,检索完全跑偏
发生了什么
测试阶段大家都在问很完整的问题,上线之后真实用户是这样问的:
- 「上次说的那个报销流程怎么弄?」
- 「之前那个问题解决了吗」
- 「还有呢」
这种问题里根本没有检索锚点,向量数据库不知道「上次」「之前」指的是什么,只能返回莫名其妙的结果。
为什么会这样
RAG 的检索是无状态的,每次检索只看当前这条 Query,完全不知道之前聊了什么。
怎么解决
Query 改写:检索之前,先用 LLM 把用户的模糊问题结合对话历史改写成一个完整、独立的检索 Query:
from openai import OpenAIclient = OpenAI()def rewrite_query(history: list[dict], user_query: str) -> str: # 只取最近 4 轮,太长引入干扰 recent = history[-4:] history_text = "\n".join(f"{m['role']}: {m['content']}"for m in recent) prompt = f"""根据以下对话历史,将用户的最新问题改写为一个完整、独立的搜索查询。只输出改写后的查询,不超过 30 字,不要任何解释。对话历史:{history_text}用户最新问题:{user_query}改写后的查询:""" resp = client.chat.completions.create( model="gpt-4o-mini", # 改写任务用 mini 就够,省钱 messages=[{"role": "user", "content": prompt}], temperature=0 # 改写不需要创造力,temperature=0 保证稳定输出 ) return resp.choices[0].message.content.strip()# 实际效果示例:# 用户说:"上次说的那个报销流程怎么弄?"# 改写后:"差旅费报销流程及所需提交材料" ← 这才能检索到东西这步成本极低,每次改写消耗 Token 不超过 200,换来多轮对话场景下检索质量的大幅提升,强烈建议无脑加上。
七、坑六:文档更新了,知识库还活在过去
发生了什么
某个功能的操作流程改了,新文档上传了,但向量数据库里还是旧版本。
用户问新功能怎么用,AI 给出旧答案,还给得理直气壮。
怎么解决
给每个 Chunk 打上元数据,用内容哈希做变更检测,设计增量更新机制:
import hashlibdef upsert_document(doc_id: str, text: str, meta dict, vector_store): # 计算内容哈希,内容没变就跳过,节省 Embedding 费用 content_hash = hashlib.md5(text.encode()).hexdigest() existing = vector_store.get_metadata(doc_id) if existing and existing.get("content_hash") == content_hash: print(f"[跳过] {doc_id} 内容未变化") return # 删除该文档的所有旧 Chunk vector_store.delete(filter={"doc_id": doc_id}) # 重新切分、向量化、插入 chunks = splitter.split_text(text) vectors = embed(chunks) records = [ { "id": f"{doc_id}_chunk_{i}", "vector": vectors[i], "text": chunk, "metadata": { **metadata, "doc_id": doc_id, "chunk_index": i, "content_hash": content_hash } } for i, chunk in enumerate(chunks) ] vector_store.upsert(records) print(f"[完成] {doc_id} 更新,共 {len(chunks)} 个 Chunk")建议在 CI/CD 流程里挂一个自动同步脚本,文档仓库有变更就触发更新,彻底解决知识库过期的问题。
八、把这些坑串起来,看完整的流程
经历了上面这些之后,整个 RAG 流程大概长这样:
【建库阶段】 文档 → 递归语义切分(chunk_size=500, overlap=80) → bge-m3 向量化 → 存入向量数据库(带 doc_id、内容哈希等元数据) → 文档变更时自动增量更新【查询阶段】 用户输入 → Query 改写(结合近 4 轮对话历史,gpt-4o-mini) → 混合检索(向量 + BM25,top_k=10) → Rerank 精排(bge-reranker-v2-m3,保留 top_3) → 拼入 Prompt → LLM 生成回答不需要一次性全上,先从最痛的那个坑开始解决,每解决一个,效果就会有一次明显跃升。
九、最后说几句真心话
RAG 这个方向入门门槛很低。LangChain、LlamaIndex 封装得很完善,三十行代码就能跑起来一个 demo,然后你会觉得「这也太简单了」。
但这正是最大的陷阱所在。
**Demo 跑通不等于生产可用。**真正的难点有三个:
**一是评估体系。**你怎么知道 RAG 效果是在变好还是变坏?不能只靠「感觉」,得有指标(检索召回率、答案忠实度、RAGAS 评分),得有 benchmark 数据集,得能量化比较每次优化前后的差异。这件事很多人懒得做,然后调参全靠玄学。
**二是数据质量。**垃圾进垃圾出。文档里大量重复内容、排版混乱的 PDF、表格被提取成乱码——再好的检索策略也救不了一份烂文档,预处理阶段该花的时间一点都省不了。
**三是对业务的理解。**用户最常问什么?他们的问法有什么规律?哪类问题答错了代价最大?这些问题的答案,直接影响你 Chunk 策略、检索权重、兜底逻辑的设计。技术是工具,业务才是靶心。
我现在对 RAG 的判断是:**这是一个工程细节决定成败的方向,不是一个算法壁垒很高的方向。**把本文这几个坑都填完,再搭好评估体系,80 分以上的 RAG 系统大多数团队都能做到。
如果你也在做 RAG,欢迎评论区聊聊你踩过的坑——尤其是那种「代码没报错、结果就是不对」的情况,说不定比我的更离谱。
学AI大模型的正确顺序,千万不要搞错了
🤔2026年AI风口已来!各行各业的AI渗透肉眼可见,超多公司要么转型做AI相关产品,要么高薪挖AI技术人才,机遇直接摆在眼前!
有往AI方向发展,或者本身有后端编程基础的朋友,直接冲AI大模型应用开发转岗超合适!
就算暂时不打算转岗,了解大模型、RAG、Prompt、Agent这些热门概念,能上手做简单项目,也绝对是求职加分王🔋
📝给大家整理了超全最新的AI大模型应用开发学习清单和资料,手把手帮你快速入门!👇👇
学习路线:
✅大模型基础认知—大模型核心原理、发展历程、主流模型(GPT、文心一言等)特点解析
✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑
✅开发基础能力—Python进阶、API接口调用、大模型开发框架(LangChain等)实操
✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用
✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代
✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经
以上6大模块,看似清晰好上手,实则每个部分都有扎实的核心内容需要吃透!
我把大模型的学习全流程已经整理📚好了!抓住AI时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~