给大模型装一个可检索的“外挂知识库”,最早被 RAG(Retrieval-Augmented Generation,检索增强生成)这个概念系统化。说白了,RAG 不是让模型记住更多,而是让它在回答问题之前,先到你的知识库里查一遍资料,再把查到的资料交给大模型组织语言。知识库构建,就是这套流程里最基础、也最影响效果的一环。这篇是 Lang-Agent 系列的第 5 篇,前面我们把 Agent 的骨架搭了起来:模型调用、工具调用、意图判断、对话状态管理。但 Agent 跑起来之后很快会遇到一个问题——它不知道你手头那堆私有文档、产品手册、历史工单里写了什么。这篇文章我会从 RAG 的原理讲到知识库构建的完整流程,然后带你手写一个最小可用的检索链路,最后把我在实际项目中踩过的调优和排查经验一并倒出来。
1. 先想清楚:知识库到底在解决什么问题
1.1 大模型的“记忆边界”在哪里
很多人对大模型有一个误解,觉得它什么都知道,实际上它的知识边界相当明显。预训练阶段喂进去的语料决定了它的知识范围,这个范围有明确的截止时间。你今天写的一份产品说明、昨天产生的一批工单数据、公司内部的流程规范,它统统不知道。
这种“不知道”在对话里通常表现为两类。一类是答得特别空泛,比如你问“我们公司退款政策是什么”,它给出一段通用的电商退款规则,看着很有道理,但和你实际业务对不上。另一类更麻烦,就是它几乎不会承认自己不知道,会顺着你的问题编出一套看起来很完整的答案,这就是所谓的“幻觉”。幻觉在 Agent 场景下会被放大,因为 Agent 经常要把大模型回答直接当成最终产出,没有人工复核环节。
我在实际项目里的体会是,与其指望模型“学进去”,不如让它“查得到”。这也是 RAG 能成为当前主流方案的根本原因:它绕开了“让模型记住”这个难题,把问题变成“把外部知识按时按需送到模型嘴边”。
1.2 RAG 的核心链路:检索、增强、生成
RAG 的全称已经写得很明白了,Retrieval-Augmented Generation。它做的事情可以分为两部分。
第一部分是离线的索引构建。原始文档要先被加载进来,清洗掉噪音,然后切分成一个个合理大小的文本片段,这些片段再通过嵌入模型转成向量,最后连同文本原文、来源信息一起存进向量数据库。这个阶段是一次性的,知识库更新的时候才需要重新跑。
第二部分是在线的问答链路。用户提一个问题时,先把问题也转成向量,再去知识库里检索出最相关的几个文本片段,然后把“问题 + 检索到的片段”一起拼成一个带上下文的提示词,交给大模型生成最终回答。
整个过程可以类比成开卷考试。大模型是考生,知识库是参考书。考生不需要背下整本书,只需要在接到考题之后,快速翻到相关章节,照着里面的内容组织答案。RAG 就是那个“翻书”的动作。
1.3 为什么不直接微调,或者把全文塞进上下文
每次讲 RAG,一定会有人问:能不能直接微调?能不能把文档全塞进上下文?这两个问题我在不同场合都被问过很多次,答案是看场景。
| 方案 | 优点 | 代价 | 适合场景 |
|---|---|---|---|
| RAG | 知识可随时更新、可溯源、成本低 | 需要维护索引和检索链路 | 私有知识多、内容经常变的场景 |
| 微调 | 能改变模型语气和固定行为模式 | 成本高、新知识容易学不牢、更新要重新训练 | 风格迁移、特定领域术语强化的场景 |
| 扩展上下文 | 实现最简单,直接拼接 | 长文本注意力衰减,中间部分常被漏掉,且 token 成本高 | 单次问题涉及的资料量有限、且预算充足的场景 |
“把全文塞进上下文”的问题尤其隐蔽。很多模型的上下文窗口现在做到了几十万甚至上百万 token,看起来足够装下一整本手册,但实测下来,当资料很长时,模型往往只关注开头和结尾部分,中间的关键信息经常被“漏读”,业界管这个叫 lost in the middle。而且每问一次就要带上全量资料,成本也很容易失控。
所以我现在的判断标准很简单:知识是静态的、量不大,就用长上下文;知识是动态的、需要频繁更新,或者希望回答能指向具体来源,就上 RAG。
2. 知识库构建的四步流程拆解
2.1 输入源接入与文本清洗
构建知识库的第一步不是写代码,而是搞清楚“你的知识到底长什么样”。我接过不少知识库项目,输入源大致有这几类:Markdown 文档、Word 文档、PDF、HTML 页面、数据库里存的工单记录、甚至聊天记录导出。每一类的处理难度不一样。
纯文本和 Markdown 是最省心的,直接读进来基本就能用。Word 和 PDF 需要先做文本抽取,PDF 里如果是扫描件还需要 OCR,双栏排版的论文类 PDF 抽取完内容顺序是乱的,这种问题的处理成本往往比想象中高很多。HTML 要先剥掉标签、导航菜单、页脚这些噪音。数据库记录则要考虑字段组合成可读文本的模板。
文本清洗是整个知识库质量的地基。我习惯在加载之后做这一串操作:统一换行符、去掉不可见字符、按语言修复错误断行、去掉重复的页眉页脚、压缩连续空行。看似不起眼,但能直接影响后续切分质量。一个很常见的真实场景:PDF 抽取后中间插入了大量无意义换行,导致一句完整的话被切成两半,检索时再被切分器一切,语义就彻底碎了。
清洗完还要做一次去重。很多企业内部文档存在大量冗余版本,同一个规范可能在多个文档里重复出现。不处理的话,检索时同一主题返回的片段会很相似,挤占有限的名额。简单做法是对整个文档做一次 SimHash 或者按标题+首段做近似去重。
2.2 切分:决定知识库效果的第一道关卡
清洗完的文档通常很长,不能整篇丢给模型。原因有两个:一是嵌入模型对输入长度有上限,太长的文本向量化会截断;二是检索阶段我们希望返回的是“小而准”的证据块,而不是一整篇长文。所以你需要在文档里切出一个个“合理的知识单位”。
这里有两个最核心的参数:chunk_size 和 overlap。chunk_size 是每个切片的长度,overlap 是相邻切片之间重叠的部分。为什么要 overlap?因为一个语义完整的内容很可能跨越了切分边界,比如一段讲“退款规则”的话,前半段在切片 A,后半段在切片 B,检索时如果只命中 A,拿到的信息就不完整。加一点重叠就是给边界内容留个缓冲带。
切分基础参数只能凭经验给一个大致范围:中文场景下 chunk_size 通常在 150 到 500 字之间,overlap 在 20 到 50 字之间。但更重要的不是具体数值,而是切分逻辑。固定字符数切分是最简单的,也最容易切碎语义;更好的做法是沿着文档结构切:先按 Markdown 标题、段落、列表项来切,遇到超长的段落再往下按句子或固定窗口切。
我给你的建议是:把“保持语义完整”放在“每片长度均匀”前面。宁可切片长短不齐,也不要在句子的中间硬切。
2.3 向量化:让文本变成可计算的距离
切分完的每一个片段,都要通过嵌入模型转成一个向量。这个向量的含义是:把这段文字的语义压缩到一组数值里。有了向量,我们才能计算两个文本“像不像”。
嵌入模型的选择有一些关键考量。第一是语言能力,中文场景一定要选中文效果好的模型,很多英文模型切中文会明显水土不服。第二是向量维度,维度越高表达能力越强,但存储和计算成本也越高,常见范围在 384 到 3072。第三是截断长度,嵌入模型一般也有输入上限,如果你的切片超出限制,超出部分会被直接丢弃,信息就丢了。第四是更新频率和成本,有些模型闭源且按调用量计费,有些开源模型可以本地部署,需要你根据项目规模和预算来权衡。
向量化之后,判断两个文本是否相关用的是相似度指标。最常见的是余弦相似度,公式不复杂,本质上就是计算两个向量夹角的余弦值。夹角越小,相似度越高。之所以不用欧几里得距离,是因为嵌入模型输出向量的模长通常并不统一,直接算距离会受向量长度干扰,而余弦相似度只关心方向,更符合“语义相关性”的直觉。
2.4 向量索引、元数据与持久化
向量化完成后,就可以建立索引了。生产环境里这一步会交给专门的向量数据库来做。小规模场景下不少人甚至直接用内存数组就能扛住,等数据量上来再迁到专用引擎。
向量数据库的核心能力是 ANN 近邻搜索,也就是在百万级向量里快速找到与查询向量最相似的几个。不同库的差异主要体现在索引算法、压缩策略和是否支持元数据过滤上。单元选择上不用太纠结,初期选一个社区活跃、文档全的即可,后续再根据并发和数据量评估。
元数据设计是很多人开始做知识库时容易忽略的一环。我建议每个切片的元数据至少包含四类信息:来源文档标识、所在章节或页码、最后更新时间、权限或部门标签。有了这些,你才能在检索时做过滤,比如只看 2024 年之后更新的资料,或者某部门员工只能检索本部门文档。元数据还可以用来做引文溯源,回答里附上“本条答案参考了某文档第几章”,这对企业内部知识库的可信度非常重要。
持久化同样需要考虑。文档是持续更新的,知识库不应该每次全量重建。常规做法是给每个文档算一个哈希值,入库时检查哈希是否变化,只在变化时重新切分和向量化。删除也一样,源文件删除时要在索引里同步移除对应向量和元数据。
3. 实操:手写一个最小可用的知识库
为了让你真正理解上面讲的这套流程,我这里提供一个纯标准库 Python 实现。生产环境当然可以直接使用成熟的生态组件,但学习阶段把核心机制手写一遍,比什么概念都记得牢。
3.1 准备一份结构化的样例语料
我先准备一份简短的设备巡检与客服工单示例文档。为了展示“结构切分”,我用 Markdown 风格的标题来组织内容。
SAMPLE_DOC = """ ### 设备巡检项 巡检机器人每两小时执行一次环境数据采集,采集内容包括温度、湿度和粉尘浓度。 采集到的数据会实时同步到中控台,中控台每四小时生成一次环境趋势报告。 ### 异常告警处理 当温度超过设定的安全阈值时,巡检机器人会立刻触发本地声光告警, 同时向值班运维人员推送一条待处理消息。运维人员需要在十五分钟内确认告警。 ### 客服工单时效要求 客服工单在二十四小时内必须完成首次响应,重大故障工单需要同时通知值班经理。 工单状态在未解决之前,系统每六小时自动提醒一次。 ### 补货逻辑说明 仓库补货采用阈值触发机制,当商品实时库存低于预设的补货点时,系统自动生成补货建议单。 补货建议会参考过去两周的日均销量和当前采购周期。 ### 数据保留策略 原始环境数据保留周期为一百八十天,超过保留期的数据会自动归档至冷存储。 归档数据不参与实时检索,但可以按季度进行回溯分析。 ### 客户投诉升级规则 客户投诉如果在两个工作日内未得到有效处理,需要自动升级至二级主管。 升级后,原处理人仍需在工单中补充阶段性进展。重大投诉可跳过升级流程直接上报。 """这段语料覆盖了多个独立主题,切分之后每个切片应该尽量围绕一个主题。
3.2 文档加载与清洗
真实项目里你需要从文件路径加载文档,这里我直接在代码里定义文本变量,把加载和清洗函数还是写出来,方便你迁移到自己的数据上。
import re import math from collections import Counter from typing import List, Dict def load_text_from_path(path: str) -> str: # 生产环境需要按扩展名分派解析器,这里只演示纯文本读取 with open(path, encoding="utf-8") as f: return f.read() def clean_text(text: str) -> str: # 统一换行符 text = text.replace("\r\n", "\n").replace("\r", "\n") # 去掉页眉页脚常见噪音:这里用空行占位演示 text = re.sub(r"\n{3,}", "\n\n", text) # 去掉行尾多余空格 text = "\n".join(line.rstrip() for line in text.split("\n")) return text.strip()清洗规则要按你的数据特点定制,比如你的文档里如果有固定格式的编号,就可以写成正则去剔除。不要在清洗上做得太少,也不要过度清洗,核心目标是“保留正文语义,去掉展示噪音”。
3.3 结构切分实现
下面的切分函数先按 Markdown 标题切段,每个标题下的内容作为一个大块;如果大块超过最大长度,再按句子边界二次切分。这样能同时兼顾结构完整性和长度控制。
def split_by_headers(text: str) -> List[str]: lines = text.split("\n") chunks = [] current_title = None current_lines = [] for line in lines: if line.startswith("### "): if current_lines: chunk = "" if current_title: chunk += current_title + "\n" chunk += "\n".join(current_lines).strip() if chunk.strip(): chunks.append(chunk.strip()) current_title = line.replace("### ", "").strip() current_lines = [] else: current_lines.append(line) if current_lines: chunk = "" if current_title: chunk += current_title + "\n" chunk += "\n".join(current_lines).strip() if chunk.strip(): chunks.append(chunk.strip()) return chunks结构切分有个好处:检索结果带着标题,你在生成回答时能直接告诉大模型“这段来自哪一节”,也方便做来源拼接。
对于没有标题的普通文档,可以退化成按段落切分。段落仍然过长时,再用固定窗口配合 overlap 切。固定窗口切分代码也很简单:
def split_by_window(text: str, chunk_size: int = 200, overlap: int = 30) -> List[str]: text = re.sub(r"\s+", " ", text) chunks = [] start = 0 while start < len(text): end = min(len(text), start + chunk_size) chunk = text[start:end] chunks.append(chunk) if end == len(text): break start = end - overlap return chunks两种切分都建议在真实数据上做一次抽样检查,看切出来的每一条是否读得通。这一步肉眼检查比任何参数调优都重要。
3.4 向量化、索引构建与检索
这一步才是核心。为了让代码完全可运行,我不用外部嵌入模型,而是用字符 n-gram 叠加 TF 权重来生成文本特征向量。正式项目中这一步会替换成预训练嵌入模型,但整体接口完全一致。
def make_features(text: str, n: int = 2) -> Counter: text = re.sub(r"\s+", "", text.lower()) if len(text) < n: return Counter([text]) return Counter(text[i:i + n] for i in range(len(text) - n + 1)) def build_index(chunks: List[str]) -> List[Dict]: index = [] for i, chunk in enumerate(chunks): index.append({ "id": i, "text": chunk, "vector": make_features(chunk), "source": SAMPLE_DOC_PATH if "SAMPLE_DOC_PATH" in globals() else "sample", }) return index def cosine_similarity(vec1: Counter, vec2: Counter) -> float: common = set(vec1.keys()) & set(vec2.keys()) dot = sum(vec1[k] * vec2[k] for k in common) norm1 = math.sqrt(sum(v * v for v in vec1.values())) norm2 = math.sqrt(sum(v * v for v in vec2.values())) if norm1 == 0 or norm2 == 0: return 0.0 return dot / (norm1 * norm2) def search(query: str, index: List[Dict], top_k: int = 3) -> List[Dict]: qv = make_features(query) scored = [] for item in index: score = cosine_similarity(qv, item["vector"]) scored.append((score, item)) scored.sort(key=lambda x: -x[0]) return [{"score": s, "id": item["id"], "text": item["text"]} for s, item in scored[:top_k]]如果用预训练嵌入模型,make_features这个函数就等价于embed_model.encode(text),返回的是一个定长向量,其余流程完全一样。
3.5 接入问答链路
现在把上面的模块串起来。先把样例文档切分成切片、建索引,再写ask函数做完整问答。
def build_knowledge_base(doc_text: str) -> List[Dict]: doc_text = clean_text(doc_text) chunks = split_by_headers(doc_text) # 如果某个切片过长,再按窗口切 final_chunks = [] for chunk in chunks: if len(chunk) > 150: final_chunks.extend(split_by_window(chunk, chunk_size=100, overlap=20)) else: final_chunks.append(chunk) index = build_index(final_chunks) return index def llm_generate(messages: List[Dict]) -> str: # 生产环境请替换为你的模型服务调用:传入多轮消息列表,返回回答文本 # 此处保留接口,不内置具体实现 raise NotImplementedError("请接入你自己的大模型调用服务") def ask(question: str, index: List[Dict], top_k: int = 3) -> str: hits = search(question, index, top_k=top_k) context = "\n\n".join(f"[切片{i}] {h['text']}" for i, h in enumerate(hits)) prompt = ( "请根据以下给定的资料回答问题。如果资料中没有相关信息," "请直接回答“资料中未找到相关内容”,不要自行编造。\n\n" f"资料:\n{context}\n\n问题:{question}" ) messages = [ {"role": "system", "content": "你是企业内部知识助手,回答要简洁准确,并优先引用资料。"}, {"role": "user", "content": prompt}, ] return llm_generate(messages)这段代码把整个 RAG 链路串起来了:检索增强通过拼进 prompt 实现,生成阶段留给你现有的模型服务。实际运行时,如果你本地没有模型服务接口,可以把llm_generate临时替换成打印 prompt,先验证检索效果。
我拿“工单首次响应时间是多久”这个问题跑过检索,search函数返回的 top 切片会命中“客服工单时效要求”那段,余弦相似度明显高于其他切片。这就是一个最小可用的知识库闭环。
4. 检索效果与召回率调优实战
4.1 先定位问题出在哪个环节
知识库跑起来之后,最常听到的反馈是“答案不对”。但“答案不对”太笼统了,问题可能出在任何一个环节。我定的排查顺序是固定的:先看检索,再看提示词,再看生成。
第一步,把search的原始返回结果直接打印出来,看看 top 切片跟问题到底相不相关。如果切片本身就不相关,那就是索引构建或检索策略的问题。如果切片是相关的,但最终回答仍然错误,那就是提示词不够明确,或者模型没有正确利用上下文。如果切片、提示词、模型都没问题但质量不稳定,那就要检查是不是每次检索结果差异很大,这通常和切分粒度、embedding 稳定性有关。
这个“先看中间产物”的习惯能帮你节省大量时间。不要一上来就换更大的模型或者改提示词,先确认检索这个最不确定的环节。
4.2 用数据调切分参数,而不是凭感觉
切分参数没有一个放之四海皆准的固定值,它跟你的文档风格强相关。我在不同项目中的经验大致如下:
| 文档类型 | chunk_size 参考 | overlap 参考 | 切分策略 |
|---|---|---|---|
| 技术文档、规范类 | 300 - 500 字 | 30 - 50 字 | 按标题+段落为主 |
| 客服工单、问答记录 | 100 - 200 字 | 20 - 30 字 | 按单条记录切,不做窗口拼接 |
| 长篇小说、叙事文本 | 500 - 1200 字 | 50 - 80 字 | 按章节切,保留上下文 |
| 财报、研报类 | 200 - 400 字 | 20 - 40 字 | 按章节标题切,小标题作为元数据 |
建议你构建一个几十条问题的测试集,每个问题对应期望命中的文档 ID,调参后统计召回率。这个测试集不用大,能覆盖主要主题即可。我见过太多人在调参上凭感觉,最后花了一整周,效果还是忽上忽下。用数据驱动,两轮就能收敛。
4.3 混合检索:让向量和关键词互相补位
向量检索擅长处理“语义相似但措辞不同”的问题,比如问“工单多久要响应”和文档里写“二十四小时内完成首次响应”,向量模型能关联起来。但它也有弱点,比如专业缩写、产品型号、精确编号这些文本,向量模型偶尔会“脸盲”。关键词检索在这类场景下反而更准确。
所以生产环境我基本都会做混合检索。简单实现是让向量检索和 BM25 分别跑一遍,拿到两个结果列表。每个文档有一个向量分和一个关键词分,最后用线性加权融合,比如final_score = 0.7 * vector_score + 0.3 * keyword_score。权重需要在小测试集上调,不同知识库的最佳权重差异很大。
我曾在某个设备维修手册项目中,只靠向量检索时,型号“RK-100”这种精确编号经常匹配到错误文档。加上关键词权重之后,命中率一下子提高了十几个点。混合检索的代码改动量不大,收益却非常明显。
4.4 重排序:粗召回之后做精排
如果混合检索已经帮你召回了比较候选,但排序还不够准,下一步就是加重排序。粗召回阶段的目标是“别漏”,所以可以多返回一些候选,比如 top 50。精排阶段再用更强的交叉编码模型逐对计算“问题-切片”的相关性,只取排序最前面的 top 3 到 top 5 进入生成。
重排序模型和普通嵌入模型不同,它不是分别编码后算余弦相似度,而是把问题和切片拼接在一起编码,能让两者充分交互,相关性判断通常更准,代价是计算成本更高。
对于中等规模的项目,如果你的嵌入模型检索已经够准,重排可以晚一点再加。我一般是在“明显感觉系统差一点但说不清差在哪”的阶段引入重排,而不是一上来就上全套。
4.5 元数据过滤与时效性控制
调优过程中容易被忽略的是元数据过滤。比如一个知识库里既有产品说明书又有销售话术,用户来自技术支持部门时,应该优先检索产品文档,过滤掉销售内容。实现上就是在检索前先限定department == "support"这样的条件,有些向量数据库会把过滤整合进检索过程,避免先全量召回再过滤的性能问题。
时效性也是一个常见需求。我之前做运营知识库时就发现,旧的促销规则会干扰当前规则的回答。后来强制把“仅检索近三个月文档”作为默认策略,效果立刻改善。如果你的业务知识变化频繁,建议在每条向量上存update_time,再在检索参数里加上时间过滤。
5. 常见问题速查与避坑心得
5.1 典型问题排查速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 检索结果明显不相关,但关键词包含 | 向量模型对领域术语理解不足 | 加入关键词混合检索,或换领域适配的嵌入模型 |
| 相关片段在 top 3 里出现了,但答案仍然错误 | 提示词没有限定必须依据资料回答 | 在 prompt 中明确“只能根据资料回答,不得编造”,并可要求逐条标注来源 |
| 同一问题每次答案差异大 | 检索到的切片集合不稳定,或生成温度过高 | 增加 top_k 稳定性检查,适当降低温度,检查切分是否有重叠噪音 |
| 答案只覆盖了一部分信息 | 关键内容被切分边界切断 | 增大 overlap,或改为按句、按段切分 |
| 文档过长时检索很慢 | 暴力扫描计算量过大 | 换用 ANN 索引,或对索引做分片 |
| 更新文档后老答案仍然出现 | 增量更新未生效,旧向量未删除 | 按文档哈希对比,重建时先删旧向量再入库 |
| 知识库内容互相冲突时回答混乱 | 检索结果里新旧版本混在一起 | 引入自动筛选规则,优先高版本或高权重来源 |
| 收回的回答没有依据,无法审计 | 缺少引文元数据 | 在切片的元数据里存来源和章节,回答后拼出出处 |
这个表是我做知识库调试时的复盘模板,每个项目基本都会命中其中三四条。
5.2 踩过几次坑之后,我最深刻的几条体会
第一个坑是 PDF 抽取。我最早做知识库时,用现成 PDF 解析库处理双栏论文,结果段落顺序错乱,检索结果一塌糊涂。后来在处理管线里手动加了一步“按两列切分后分别读取”的逻辑,才算稳定。如果你要处理的文档里有大量 PDF,建议在清洗阶段就花时间把版面结构分析清楚,这个投入会在后面的检索效果里加倍返还。
第二个坑是默认 chunk_size。我之前盲目套用网上常见的 500 字参数处理客服工单,结果每条工单都被切进不同切片里,问题描述和解决方案经常被拆开。后来改成“按每条工单独立切分,不跨记录”,效果立竿见影。切分策略一定是由内容形态决定的,参数只是辅助。
第三个坑是“测试凭感觉”。很多次我以为调优有效,随手问几个问题感觉不错就收工,结果一上回归测试立刻翻车。后来我做了一套固定测试集,每次改动跑一遍,用召回率和相关率打分,才把调优变成一件可控的事。评测集不需要很大,三五十条就足够发现问题。
5.3 下一步扩展方向
如果你这个最小知识库已经跑通,下一步可以按这个顺序扩展:先加元数据过滤和时间过滤,解决权限与时效问题;再上混合检索,解决专业术语和编号匹配问题;然后引入重排;最后建一套完整的增量更新和评估机制。
增量更新要特别注意删除策略。很多向量数据库支持按元数据删除文档,但如果你存的是“文档哈希+文档 ID”,删除时就能精确定位。我建议每次更新都走同一个流程:对比哈希、清理旧切片、重新向量化、统一写入。这样不会留下孤儿向量污染结果。
评估机制则是长期运营的关键。知识库不是建完就结束了,它和代码一样需要持续维护。建一个几十条的验收集,每次改完切分参数或者换嵌入模型,跑一遍对比数据,哪条提升了、哪条反而退步了,一目了然。
我实际把这些步骤完整走通之后,最大的体会是:先用最简单的方式跑通最小闭环,再用数据决定优化方向。很多人一上来就上混合检索、重排序、多路召回一堆方案,结果连最基础的检索命中都有问题,排查起来非常痛苦。我从零开始就只用一个向量搜索跑通,再顺手加关键词召回,效果已经很稳。最后再分享一个小技巧:把每一条测试问题连同期望命中的文档 ID 存成一个 JSON 验收表,每次调整完切分参数或嵌入模型后跑一遍批量验证,召回率一对比就有数了。这个习惯我在后续每个知识库项目里都在用,真的能救你很多次。