1. 内容整体设计与思路拆解
先交代背景:今天这篇笔记源于我近期一直在跟的一个实际项目——给某公司内部的资料库做一个“问答机器人”。项目本身不复杂,但真正做起来才发现,把大模型接进业务系统并不是“调个接口”那么简单。我把2026年1月19日这一天的学习重点和实操记录整理成了这篇笔记,主要围绕检索增强生成(RAG)应用从零到落地的过程。
1.1 为什么要把“学习笔记”定成RAG主题
很多人看到“RAG”会想到一堆名词:向量库、embedding、chunking、rerank……但笔记里如果能把这些词串成一条清晰的决策链,才算真正学明白。我选择RAG作为这一天的学习主题,是因为它正好覆盖了大模型应用开发中最核心的痛点:模型不知道私有知识、回答容易胡编、知识更新成本高。
RAG的思路很朴素:先把你自己的文档切碎,转成向量存起来;用户提问时,先去医院侧做语义检索找出相关内容,再让大模型基于这些内容总结回答。这么一来,模型不需要记住你的内部资料,每次只要拿着“小抄”回答问题就行。这种模式适用于企业知识库、产品说明书问答、员工入职助手等场景,也是目前大模型落地最稳的一条路。
1.2 整体架构如何选型
做RAG之前先别急着写代码,得把架构摸清楚。我这一天的学习笔记里,第一件事就是画了一张“数据流向图”:
- 离线阶段:原始文档 -> 解析 -> 清洗 -> 文本切分 -> 向量化 -> 写入向量数据库
- 在线阶段:用户问题 -> 向量化 -> 向量检索 -> 重排 -> 拼接Prompt -> 大模型生成 -> 答案返回
这个链路看着直白,但每一步都有不少隐藏的坑。例如文本解析如果用了不合适的解析器,表格和PDF里的内容会乱成一团;切分策略不对,语义被拦腰截断;向量检索召回了一堆无关文档,最后大模型就容易被“带偏”。所以我的笔记里专门分成了“索引构建”和“查询推理”两条线,后面逐步拆解。
在选型层面,我坚持一个原则:能用成熟组件就不要自己写,但也不能被封闭生态绑死。向量数据库、Embedding接口、LLM接口这三块尽量做成可插拔的,哪怕前期多写几个包装类也值得。这样后续换供应商、换模型版本,只要改一层配置,不用动业务代码。
2. 核心细节解析与实操要点
2.1 文本切分:chunk_size 与 overlap 怎么定
文本切分是RAG里最能“决定成败”的细节。如果切得太粗,一个chunk里塞了多个主题,检索时相关性容易被稀释;切得太细,语义不完整,模型连上下文都读不通。我在项目里试过从200到2000字的多个chunk_size,最终稳定在500字左右,overlap设成50字,这个组合对中文技术文档的效果最好。
overlap的作用是防止语义被切断。想象一下,如果你把一段话从中间剪开,恰好把“虽然天气很好”和“但是风特别大”分到两段,检索“天气”时只能看到前半句,判断就失真了。overlap相当于让相邻片段重叠一小段,保住边界上的上下文。切分时还要尽量按标题、段落边界切,不能机械地按字符数硬切。我用的方案是:优先用正则找段落标记,再在段落内部按句号、分号切成短句,最后拼成500字左右的块。
这里有三个实操要点:
- 统一换行符,别让Windows和Linux的换行差异污染切分结果。
- 对英文和数字要保留原格式,避免把“C++”切成了“C ”和“+ +”。
- 表格类内容最好单独处理,转成Markdown表格文本后再切,否则检索时常常丢列名。
2.2 向量化与索引:Embedding 模型选择
选Embedding模型时,我的笔记里记了三个对比维度:向量维度、语义能力、更新成本。向量维度越高,存储和计算开销越大,但不见得效果更好;语义能力要看你的语料是不是能覆盖专业术语;更新成本则决定了你多久能换一次模型。
实际测试中我发现,通用中文场景下,用常见的“基础Embedding接口”就能达到不错的召回率。但如果是医疗、法律、机械这种术语密集的领域,必须额外用领域语料做微调,或者至少在项目里先跑一批业务样本验证。我在某医疗项目里,把通用向量模型换成领域微调后的版本后,召回率提升了15%左右,这个差距很悬殊。
索引层面,我重点记录了三类向量索引的取舍:
| 索引类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 暴力检索 | 精度最高,实现简单 | 数据量大时速度慢 | 百级文档、离线验证 |
| HNSW | 查询快、扩展性强 | 内存消耗大、参数敏感 | 万级到千万级向量 |
| IVF | 节省内存、速度快 | 召回精度略低 | 海量向量、对精度不敏感 |
我现在的默认选择是HNSW,因为RAG场景里query时延敏感,HNSW能在毫秒级返回TopK结果。但需要提醒别一上来就用,新手阶段先用暴力检索跑通再切换,否则参数调不明白,容易得出错误结论。
2.3 检索链路:重排与混合检索
单靠向量检索并不总能解决问题,因为向量相似度不等于语义相关度。比如用户问“这个产品的保修期是多久?”,如果你文档里写着“保修期为购机之日起一年”,向量距离可能很近,但排在前面的可能是很多“保修、期限、产品”相关的高频片段。为了解决这个问题,我加了重排阶段。
重排的方案很多,可以使用专门的Cross-Encoder模型,也可以用一个更强的大模型对召回结果做粗筛。我一般先向量召回50个候选,再用重排模型选出Top5。这个“粗召回+细排”的结构能够显著提升最终生成效果,虽然没有精确统计过,但至少减少了30%的答非所问。
混合检索也是我强烈建议在笔记里写一笔的点。很多场景下,关键词精确匹配比语义匹配更可靠,比如型号“X-1000”、日期“2025-12-31”、报错码“E401”。混合检索会把关键词匹配和向量检索的结果做一个加权合并,让两类信号互补。我在代码里给向量检索和关键词检索分别设置了权重,先用规则从问题里抽关键词,再配合向量检索,效果稳定不少。
3. 实操过程与核心环节实现
3.1 环境准备与依赖
我这次学习笔记里没有用特别复杂的技术栈,整条链路都是基于Python搭建的。环境依赖大致如下:
- 语言模型接口:一个支持函数调用/流式输出的大模型API客户端
- Embedding接口:与语言模型配套的text-embedding服务
- 向量数据库:一个本地可跑的轻量级向量库,用于索引和检索
- 文档解析库:能够读取PDF、Markdown、Word格式的解析工具
这些组件我都用了一层薄封装,避免业务代码直接依赖某个供应商SDK。换组件时,只需要改工厂类里的一个参数。
提示:跑通流程之前,先把依赖隔离好,用单独的虚拟环境。RAG组件版本冲突非常常见,我在项目里就遇到过解析库升级后,把Markdown标题的层级信息全部丢掉的问题。
3.2 核心代码实现
下面这段是我整理后的简化版实现,重点突出“索引构建”和“查询”两条主链路。
# 配置各个组件的统一入口 config = { "chunk_size": 500, "overlap_size": 50, "top_k_candidates": 50, "top_k_final": 5, "vector_db_type": "local_hsnw", "embedding_dim": 1024, } class DocumentLoader: """读入不同格式文档,统一转成纯文本。""" def load(self, file_path: str) -> str: # 根据扩展名选择对应的解析器 ext = file_path.rsplit(".", 1)[-1].lower() if ext == "pdf": text = self._parse_pdf(file_path) elif ext == "md": text = self._parse_markdown(file_path) elif ext in ("docx", "doc"): text = self._parse_word(file_path) else: text = self._parse_plain(file_path) # 清洗:去掉多余空行、统一换行符 return clean_text(text) class TextSplitter: """按段落和长度双重约束切分文本。""" def split(self, text: str, chunk_size: int, overlap: int) -> list[str]: paragraphs = re.split(r"\n\s*\n", text) chunks = [] current = "" for para in paragraphs: # 如果当前块加段落超长,则先存下当前块,再考虑是否和段落combined if len(current) + len(para) > chunk_size and current: chunks.append(current) current = current[-overlap:] if overlap > 0 else "" current += para + "\n" if current.strip(): chunks.append(current) return chunks class VectorStore: """对向量数据库做一层薄封装,上层不感知具体实现。""" def __init__(self, config): self.config = config self._client = create_vector_db_client(config["vector_db_type"]) def insert(self, embeddings, texts, metadata_list): for vec, text, meta in zip(embeddings, texts, metadata_list): self._client.upsert(vector=vec, payload={"text": text, **meta}) def search(self, query_embedding, top_k): hits = self._client.query(query_embedding, top_k=top_k) return [h.payload for h in hits] def build_index(doc_files: list[str]): loader = DocumentLoader() splitter = TextSplitter() store = VectorStore(config) for file_path in doc_files: raw_text = loader.load(file_path) chunks = splitter.split(raw_text, config["chunk_size"], config["overlap_size"]) embeddings = embed_texts(chunks) # 批量调用Embedding接口 metadata = [{"source": file_path, "chunk_index": i} for i in range(len(chunks))] store.insert(embeddings, chunks, metadata) def query(question: str): q_vec = embed_texts([question])[0] km_matches = keyword_search(question) # 并行做关键词检索 vec_matches = store.search(q_vec, top_k=config["top_k_candidates"]) merged = merge_results(km_matches, vec_matches, weight_vector=0.8, weight_keyword=0.2) reranked = rerank_by_score(question, merged)[:config["top_k_final"]] context = "\n---\n".join([m["text"] for m in reranked]) prompt = build_prompt(question, context) answer = llm_generate(prompt) return answer上面这段代码是学习笔记的骨架,但真实项目里还需要补充很多异常处理:Embedding接口超时、向量库连接断开、文档解析失败,等等。我在笔记里把这些异常记录成了独立的“坑位”,后面专门展开。
3.3 工程化落地的参数调整
代码跑通只是第一步,真正要上生产,还得做参数调优。我整理了表格记录不同参数设置下的效果变化:
| 参数 | 我常用的初始值 | 出现问题时怎么调 |
|---|---|---|
| chunk_size | 500 | 回答太碎片就调大到800;答非所问就调小到300 |
| overlap_size | 50 | 语义断裂明显时调到100 |
| 候选召回数 | 50 | 重排后效果不稳定时提高到100 |
| 最终返回数 | 5 | 上下文太长导致超时,就降到3 |
| 关键词检索权重 | 0.2 | 专业型号查询多时调到0.5 |
还有一个容易被忽略的参数:相似度阈值。并不是所有召回结果都值得进Prompt。如果某条候选的相似度低于0.35,我通常会直接丢掉,避免无关内容污染生成。当然阈值也要基于实际分布调整,不能照搬。
4. 常见问题与排查技巧实录
4.1 检索“召回差”怎么调
召回差是RAG应用里最常见的问题,具体表现就是“用户问A,你检索出来的都是一堆B”。排查时我按固定顺序来:
- 看切分:是不是chunk太大导致一个块里混入了多个主题?
- 看Embedding:是不是模型对领域术语不理解?用几个高相关问题做召回测试。
- 看检索方式:只用了向量检索,忘了关键词检索?
- 看重排:Top10候选里有正确答案但排到后面去了?调低向量权重,调高关键词权重。
有一次,所有答案都检索不到,我查到最后发现是PDF解析时的表格数据被解析成了乱码,根本没有进索引。所以排查时别一头扎进算法里,先从最上游的数据质量检查起。
4.2 幻觉问题怎么治
即使RAG链路很顺,大模型还是可能“脑补”内容。我的经验是:在Prompt里强制加上“如果上下文没有明确答案,直接回答我不知道”。这看似简单,却能大幅减少无中生有的回答。另外,在输出前端加一层“引用校验”,要求模型在关键结论后面标注来源chunkID,我再用脚本检查答案里的关键实体是否真的出现在对应chunk中。如果有疑点,就把答案打回重生成或降级回复。
我还在笔记里记录了一个心得:不要只依赖大模型“自觉”。应该把上下文来源列表原样交给用户,让用户也能自己核对。这不仅是技术方案,也是产品设计上的信任策略。
4.3 延迟与成本优化
RAG应用上线后,最大的压力往往不是准确率,而是时延和费用。我优化的方向有三个:
- 缓存:对重复问题做语义缓存,命中后直接返回历史答案,能省掉大半查询开销。
- 并发:把Embedding和LLM调用改成异步并发,而不是串行循环。
- 模型分级:简单问题用轻量模型,难问题走满血大模型。做一个简单的路由判断,就能把成本降到原来的三分之一。
这里有个小技巧,是把“问题分类器”做成一个只输出标签的极短模型,跳过复杂Prompt判断。我用这个方案之后,平均响应时间从4秒降到了1.5秒,而且答案质量没有明显下降。
最终说一个我在踩过多次坑之后的体会:做RAG学习笔记,别光盯着“模型有多聪明”,真正要死磕的是“上下文有多准”。把文本切分、检索融合、重排这三件事做扎实,哪怕模型版本老一点,效果也差不到哪去。反过来,如果只堆模型调用,检索结果乱七八糟,再强的模型也是浪费时间。希望这份笔记能让你在迭代自己的RAG应用时少走几条弯路。