Python 搭建 RAG 文档问答知识库:核心模块、落地与避坑
简介这份AI知识库系统Python源码面向希望学习知识库管理、文件上传与检索实现的开发者与计算机专业学生可用于本地化部署、课程设计或二次开发。压缩包共22个文件约53KB以10个HTML页面模板、6个Python脚本为主并含1个数据库文件、1个使用说明文本、4个字节码缓存文件以及静态资源与上传目录页面覆盖登录注册、文章与文档管理、问答检索等模块脚本承担配置、初始化、路由和启动等职责。已有68人浏览学习适合作为轻量级项目参考。源码采用模块化目录组织配置、数据库初始化与业务模块分工清晰读者可据此理解从建库、配置到页面交互和检索流程的完整实现也便于按需替换数据表与页面快速搭建自己的知识管理原型对学习Python Web项目结构、数据库建模与模板渲染也有一定参考价值。1. 上传一堆 PDF问一句就出答案这套 Python 知识库到底能干什么上个月帮一个朋友处理他们的内部资料硬盘里躺着两百多份 PDF 和 Word从产品手册到会议记录都有。他想问“去年第三季度那版接口改动到底影响了哪些模块”靠关键词搜索翻半天翻不到。我给他搭的这套 Python 写的 AI 知识库系统流程很朴素上传文档、解析成纯文本、切块、向量化、建索引提问时先检索出最相关的若干片段再把这些片段当上下文交给大模型组织答案。它适合手上有私域文档、想做一个能跑起来的问答原型又不想一上来就搞重型平台的开发者。整套东西就是为了解决这件具体事让文档能被“问”到。2. 项目定位与技术选型把检索增强问答拆成四个可替换的零件先把这个项目摆到一个正确的位置上。它不是训练模型的项目也不是一个知识图谱项目而是一套 RAG 的最小可运行实现数据从文件进来经过解析、切分、向量化三步落到磁盘查询时从磁盘把索引读出来算相似度取 top-k 片段拼成提示词最后调一次大模型接口拿答案。整条链路上的每一步都被单独拆成了一个模块你能替换其中任何一环而不动别的代码这是这类系统最该做的事。之所以强调“可替换”是因为 RAG 项目翻车的重灾区几乎都在耦合上切分逻辑写死在数据加载函数里、向量化直接内嵌在 Web 路由里、检索结果格式和提示词模板绑死。等你想换个嵌入模型或者调一下 chunk 大小就得满地改代码。这套源码的目录划分就是按数据流切的大致是这个样子ai-kb/ ├── app.py # Web 入口三个路由上传、提问、重建索引 ├── config.yaml # 所有可调参数集中在这里 ├── requirements.txt ├── core/ │ ├── loader.py # 多格式文档 - 纯文本 │ ├── splitter.py # 纯文本 - chunk 列表 │ ├── embedder.py # 文本列表 - 向量列表 │ ├── index.py # 向量 元数据 - 本地索引文件 │ ├── retriever.py # 问题 - top-k 片段 - 提示词 │ └── llm.py # 提示词 - 大模型回答 ├── storage/ │ ├── raw/ # 原始文件 │ └── index/ # vectors.npy meta.json ├── scripts/ │ ├── build_index.py # 离线建库 │ └── eval_retrieval.py # 小批量召回评估 └── web/ ├── templates/index.html └── static/模块职责看一眼就懂关键在于依赖方向app.py只依赖retriever和llmretriever只依赖index和embedder谁都不反向引用 Web 层。这意味着你可以写一个命令行脚本用同一套 core 包跑批处理不用起服务。2.1 技术栈的取舍为什么不用重型框架堆功能技术选型上我偏向保守理由都是踩坑换来的。Web 层用 Flask因为整个服务只有三个接口、没有复杂鉴权用更重的框架纯属给自己加启动负担。向量检索不引入独立向量数据库直接在内存里用一个numpy矩阵算点积几万条 chunk 的规模下毫秒级返回落盘就是vectors.npy加一个meta.json备份和迁移都是拷文件这一点在给非专业运维的团队交付时特别重要。环节选型替换成本什么时候该换文档解析pypdf python-docx 纯文本低加一个分支即可遇到扫描件、图片型 PDF切分自实现按段落聚合 尾部重叠低改一个函数文档结构复杂、需要按标题层级切向量化统一接口远程 HTTP 或本地模型二选一低实现一个类方法换模型、换维度、换语言索引numpy 矩阵 npz/npy 落盘中换到向量库要改读写chunk 数量过十万、需要增量删除生成任意兼容 HTTP 协议的大模型接口低改配置换供应商、换本地部署这里有个取舍值得说清楚很多教程默认让你上向量数据库但对一个内部资料问答来说索引重建是低频操作文档更新时才做查询是高频但规模不大内存矩阵完全够。等到数据涨到几十万 chunkself.mat q这个矩阵乘的内存占用和延迟才开始难看那时候再迁移接口add/search/save/load四个方法保持不变上层一行都不用动。这就是先定接口再定实现的好处。2.2 一次提问的完整链路从请求进来到答案出去把数据流走一遍后面看代码会顺很多。用户提交问题时服务端先调用嵌入接口把问题变成同维度的向量然后在索引里做余弦相似度计算按分数倒序取前 k 条并过一个最低分阈值——这一步很关键没有阈值的话即使文档里根本没有相关内容系统也会硬塞几条低分片段给模型模型就会一本正经地编。拿到片段后按[1] [2]编号拼成上下文块塞进提示词模板连同“只依据给定资料回答、找不到就说找不到”的约束一起发给大模型。索引侧的数据流方向相反文件先落到storage/raw/解析出纯文本切分成 chunk每条 chunk 携带source文件名、chunk_id序号、text内容三个元数据向量和元数据按相同下标一一对应地写入索引。这个下标对齐是整个系统里最不能错的地方一旦mat的行顺序和meta的顺序错位检索出来的就是张冠李戴的片段而且不会报错只会答得莫名其妙。3. 核心模块拆解四段代码串起一条检索增强链路这一章按数据流顺序拆四个模块每段都能直接抄进项目里跑。省掉框架部分只看真正干活的地方。3.1 文档加载把 PDF、Word、Markdown 收敛成同一种结构加载器的目标不是“支持所有格式”而是把所有格式都变成同一个字典结构后面的切分逻辑就不用关心来源了。四种格式各写一个函数用后缀名分发# core/loader.py from pathlib import Path from pypdf import PdfReader SUFFIX_MAP {.txt: text, .md: text, .pdf: pdf, .docx: docx} def load_pdf(path: Path) - str: reader PdfReader(str(path)) pages [] for i, page in enumerate(reader.pages, start1): # 逐页提取并打页码标记答问时能把引用精确到页 text page.extract_text() or pages.append(f[p{i}]\n{text}) return \n.join(pages) def load_docx(path: Path) - str: from docx import Document doc Document(str(path)) # 只保留非空段落表格内容单独处理避免拼成一坨 paras [p.text.strip() for p in doc.paragraphs if p.text.strip()] for table in doc.tables: for row in table.rows: cells [c.text.strip() for c in row.cells] paras.append( | .join(c for c in cells if c)) return \n\n.join(paras) def load_document(path: Path) - dict: suffix path.suffix.lower() kind SUFFIX_MAP.get(suffix) if kind is None: raise ValueError(f不支持的文件类型: {suffix}) if kind text: # errorsignore 兜住少量编码不规范的 txt别让整批任务挂掉 text path.read_text(encodingutf-8, errorsignore) elif kind pdf: text load_pdf(path) else: text load_docx(path) return {source: path.name, suffix: suffix, chars: len(text), text: text.strip()}load_document返回的字典里带chars字段不是多余的批量入库时先用它在入库前过滤掉解析失败的空文档比如纯扫描的 PDF 提取出空字符串比事后查索引为什么少了几条要省事得多。PDF 里的[pN]标记是给溯源用的拼上下文时这个标记会跟着片段一起进提示词回答里就能标出“出自第几页”。Word 的表格我单独拍了成|分隔的行实测比丢掉表格或让paragraphs顺序混乱要靠谱。有个细节要注意extract_text()对扫描件的返回是空字符串而不是抛异常所以入库脚本里一定要判断len(text) 50就跳过并记一条日志否则你会得到一堆空 chunk检索时分数全是噪声。3.2 文本切分chunk 大小、重叠和超长段落怎么处理切分是整套系统里对效果影响最大的一步比换模型的影响还大。切太碎片段缺上下文模型答得支离破碎切太大一个片段里混了好几个主题向量被平均掉检索精度下降。我一般从 500 字符、80 字符重叠起步中文资料这个量级大概 250 到 350 个汉字能装下两三个完整段落。# core/splitter.py import re def split_text(text: str, chunk_size: int 500, overlap: int 80) - list[str]: # 先按空行切段落保留原始段落边界 paras [p.strip() for p in re.split(r\n\s*\n, text) if p.strip()] chunks, buf [], for para in paras: if len(para) chunk_size: # 超长段落按句末标点再拆短句不单独成块 units [u for u in re.split(r(?[。!?;]), para) if u.strip()] else: units [para] for unit in units: if len(buf) len(unit) chunk_size: buf unit else: if buf.strip(): chunks.append(buf.strip()) # 尾部保留 overlap 个字符让跨块的句子两头都能被检索到 buf (buf[-overlap:] if overlap and buf else ) unit if buf.strip(): chunks.append(buf.strip()) # 丢掉极短碎片它们几乎必然是页眉页脚或残句 return [c for c in chunks if len(c) 30]三个参数各管一件事。chunk_size决定单块信息量调大之前先想清楚你的文档是条目式还是长论述式条目式的可以压到 300 让每条独立overlap是后悔药主要是防止一句关键结论正好被切在块边界上、两块都只拿到半句导致怎么检索都检索不完整最后那个len(c) 30的过滤看起来粗暴但能有效清理掉目录页、页眉页脚这类高频噪声——这些碎片反复出现在多个文档里向量还特别相似是检索结果跑偏的常见元凶。切分函数是纯函数不依赖任何外部状态所以scripts/build_index.py里可以直接拿它对单个文件做试验改参数、跑一遍、看输出的块数和内容不用重建整个库。这个调试方式比在服务里改参数重启要快得多。3.3 向量化与索引落盘接口统一实现随便换向量化这块我不建议在业务代码里写死某个供应商的 SDK因为换模型几乎是必然发生的事。做法是定义一个统一入口远程调用和本地模型走不同分支上层只看返回的向量矩阵# core/embedder.py import json import urllib.request class Embedder: def __init__(self, model: str, endpoint: str , api_key: str , dim: int 384): self.model, self.endpoint model, endpoint self.api_key, self.dim api_key, dim def embed(self, texts: list[str]) - list[list[float]]: if not self.endpoint: # 没配远程服务时走本地模型接口保持完全一致 return self._embed_local(texts) req urllib.request.Request( self.endpoint, datajson.dumps({model: self.model, input: texts}).encode(utf-8), headers{Content-Type: application/json, Authorization: fBearer {self.api_key}}, ) with urllib.request.urlopen(req, timeout30) as resp: data json.loads(resp.read().decode(utf-8)) # 不同服务返回结构略有差异这里按 data[i].embedding 取 return [row[embedding] for row in data[data]] def _embed_local(self, texts): from sentence_transformers import SentenceTransformer model SentenceTransformer(self.model) return model.encode(texts, batch_size16, normalize_embeddingsFalse).tolist()embed的入参和出参类型固定为list[str] - list[list[float]]这是整套系统的契约。批量传而不是逐条传是因为远程接口的延迟主要是网络往返一次传 32 条和传 1 条的耗时差不了太多入库几千条文档时这个差距能到十几分钟。normalize_embeddingsFalse是故意关掉的归一化统一放到索引层做避免两处都归一化或者都没归一化导致相似度算错。索引层就是一个二维矩阵加一个等长的元数据列表# core/index.py import json from pathlib import Path import numpy as np class VectorIndex: def __init__(self, dim: int): self.dim dim self.mat np.zeros((0, dim), dtypenp.float32) self.meta: list[dict] [] def add(self, vectors, metas): v np.asarray(vectors, dtypenp.float32) if v.shape[1] ! self.dim: raise ValueError(f维度不匹配: 期望 {self.dim}, 实际 {v.shape[1]}) # 统一 L2 归一化之后点积等价于余弦相似度 v v / (np.linalg.norm(v, axis1, keepdimsTrue) 1e-8) self.mat np.vstack([self.mat, v]) if self.mat.size else v self.meta.extend(metas) def search(self, qvec, topk: int 5, min_score: float 0.25): q np.asarray(qvec, dtypenp.float32) q q / (np.linalg.norm(q) 1e-8) scores self.mat q # 一次矩阵乘全库打分 order np.argsort(-scores)[:topk] return [{score: float(scores[i]), **self.meta[i]} for i in order if scores[i] min_score] def save(self, dirpath: str): d Path(dirpath); d.mkdir(parentsTrue, exist_okTrue) np.save(d / vectors.npy, self.mat) (d / meta.json).write_text( json.dumps(self.meta, ensure_asciiFalse), encodingutf-8)add里那个维度校验是我吃过亏之后加的换嵌入模型时忘了重建索引新老向量混在一起矩阵乘法会在某一维直接报错——这个还算好的最怕维度碰巧一致但语义空间不同检索结果全乱还不报错。所以切模型必重建库没有例外。min_score默认 0.25这个值跟模型强相关换模型后必须用scripts/eval_retrieval.py重标别直接沿用。3.4 检索与提示词拼装把 top-k 片段变成模型能吃的上下文检索不是取完 top-k 就完事中间还有一层拼装这一层决定了答案的可信度和长度可控性# core/retriever.py PROMPT_TMPL 你是内部资料问答助手。只能依据下面编号的资料回答资料里没有的内容直接说“资料中未找到”不要自行补充。 [资料] {context} [问题] {question} 请在答案末尾用 [编号] 标明依据例如 [1][3]。 def build_context(hits, max_chars: int 2400) - str: parts, used [], 0 for i, h in enumerate(hits, start1): block f[{i}] 来源:{h[source]} 位置:{h[chunk_id]}\n{h[text]} if used len(block) max_chars: break # 超长就截断后面的别把提示词顶爆 parts.append(block); used len(block) return \n\n.join(parts) def answer(question, hits, llm): if not hits: return {answer: 资料中未找到相关内容。, refs: []} ctx build_context(hits) prompt PROMPT_TMPL.format(contextctx, questionquestion) text llm.chat(prompt, temperature0.1, max_tokens800) refs [{source: h[source], chunk_id: h[chunk_id], score: h[score]} for h in hits] return {answer: text, refs: refs}max_chars是个硬顶作用是在召回条数偏多时兜住提示词长度避免超过模型上下文限制被截断——截断发生在哪一段是不确定的可能正好把最关键的那段切掉。temperature0.1是为了让同一问题的答案稳定问答场景不需要创造性。hits为空时直接返回固定话术不发请求既省钱又避免模型硬编。refs一起返回给前端是这个系统能落地的关键用户看到的不是一段无出处的文字而是一段带来源和分数的答案点开能核对原文。没有这一层内部资料问答基本没人敢用。4. 跑起来依赖、配置、建库、验证四步代码看完了接下来把它在本地跑通。整个过程不超过十分钟卡住的地方基本都在依赖和配置上。4.1 环境与依赖安装Python 版本建议 3.10 以上主要是类型标注和新版依赖的兼容性。依赖不多但有几个编译包在部分平台上需要额外系统库python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -U pip pip install flask pypdf python-docx numpy pyyaml # 只有走本地嵌入模型时才需要下面这个走远程接口可以不装 pip install sentence-transformerspypdf是新版包名老代码里常见的PyPDF2已经停止维护两者 API 有差异别混用。如果要处理扫描件还需要图像识别那一套依赖体积大得多按需再加不要一上来就全装装完发现问题在依赖冲突上会很难查。4.2 配置文件所有可调参数的集中地把参数集中在一个 yaml 里是为了改阈值不用改代码、也不用重新打包# config.yaml server: host: 0.0.0.0 port: 8000 splitter: chunk_size: 500 overlap: 80 retriever: topk: 5 min_score: 0.25 max_context_chars: 2400 embedder: model: embed-v1 endpoint: http://127.0.0.1:9000/v1/embeddings # 留空则走本地模型 api_key: dim: 384 llm: endpoint: http://127.0.0.1:9000/v1/chat/completions model: chat-v1 timeout: 60参数作用调整建议splitter.chunk_size单块字符上限条目式文档降到 300长论述保持 500splitter.overlap相邻块重叠字符数一般为 chunk_size 的 15% 左右retriever.topk召回条数3 到 8多了会稀释上下文retriever.min_score召回最低分换模型后必须重新标定embedder.dim向量维度必须和索引文件里保存的维度一致dim这一项最容易出错。它同时被索引层用来做维度校验所以只要模型换了而 dim 没跟着改建库时会直接抛异常。我一般把 dim 写死在配置里而不是每次去问接口就是为了让这个不一致尽早暴露。4.3 建库与启动服务先用离线脚本把storage/raw/里的文档批量灌进索引这一步和 Web 服务完全解耦# scripts/build_index.py import sys, yaml from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from core.loader import load_document from core.splitter import split_text from core.embedder import Embedder from core.index import VectorIndex cfg yaml.safe_load(open(config.yaml, encodingutf-8)) emb Embedder(**cfg[embedder]) idx VectorIndex(dimcfg[embedder][dim]) for path in Path(storage/raw).rglob(*): if not path.is_file(): continue try: doc load_document(path) except Exception as e: print(f[skip] {path.name}: {e}); continue chunks split_text(doc[text], **cfg[splitter]) if not chunks: print(f[empty] {path.name}); continue vectors emb.embed(chunks) metas [{source: doc[source], chunk_id: i, text: c} for i, c in enumerate(chunks)] idx.add(vectors, metas) print(f[ok] {path.name} - {len(chunks)} chunks) idx.save(storage/index) print(f总块数: {len(idx.meta)})每处理一个文件打一行日志这是为了中途报错时能知道停在哪份文档上。用rglob而不是glob是为了递归子目录。chunks为空时跳过并打印文件名前面提到的扫描件问题就会在这里暴露出来不会悄悄进库。建完库启动服务python scripts/build_index.py python app.py # 读取 config.yaml加载 storage/index4.4 接口验证先测检索再测生成服务起来后别急着一上来就问复杂问题按两步验证能快速定位问题出在检索还是生成# 第一步只问检索看召回的片段对不对 curl -s -X POST http://127.0.0.1:8000/api/ask \ -H Content-Type: application/json \ -d {question: 接口改动的兼容性说明, topk: 3, only_retrieve: true} | python -m json.tool # 第二步带上生成看提示词和答案 curl -s -X POST http://127.0.0.1:8000/api/ask \ -H Content-Type: application/json \ -d {question: 接口改动的兼容性说明} | python -m json.toolonly_retrieve这个开关在排查时非常有用如果返回的片段里根本没有答案那问题在切分或向量化改提示词是白费劲如果片段里有答案但模型没答出来才轮到调提示词和温度。很多人一遇到答不好就去改提示词实际上大半问题在召回侧。5. 避坑与排查五条踩出来的记录这条链路上每一环都有各自的坑而且大多不报错只是效果悄悄变差。下面几条是实际遇到过的按现象、原因、解决记下来。5.1 现象回答总是“资料中未找到”但文档里明明有先别怀疑模型。九成情况是召回侧没拿到片段被min_score拦掉了。原因通常是嵌入模型换了或者第一次接入某个远程服务向量的分数分布和标定时完全不同——有的模型相似度普遍偏高都在 0.6 以上有的普遍偏低相关内容也只有 0.2阈值沿用旧值就会全被过滤。解决办法是把min_score临时调到 0打开only_retrieve跑五个真实问题看每条的正确片段排在什么分数区间再把这个区间的下沿减 0.05 作为新阈值。这一步做完再改回配置。另外检查一下入库时normalize_embeddings和索引层的归一化是否只做了一次两处都归一化和两处都没归一化都会让分数失真后者表现为相似度动辄超过 1。5.2 现象处理长 PDF 时报错或答案被莫名截断报错一般是请求体超出模型服务的上下文上限提示词太长被拒。原因有两层一是topk开得大每块又都是满chunk_size几块拼起来就好几千字二是build_context的max_chars设得过于宽松起不到兜底作用。解决是把max_context_chars设到模型上下文的一半左右留出空间给系统提示和回答并且让build_context严格按预算截断而不是全量拼接。如果单块本身就超过预算说明chunk_size设错了先回去调切分别指望在提示词层补救。截断时优先保留高分片段这个顺序天然由search的排序保证所以千万别在拼装前对 hits 重新排序。5.3 现象重建索引后块数翻倍同一个问题召回的片段互相重复典型原因是入库脚本没有清空旧索引直接把新向量vstack上去。VectorIndex.add是追加语义重复跑build_index.py就会在旧矩阵上再叠一层。更隐蔽的情况是文档改了个名字重新上传内容一样但source不同向量几乎重合检索时同一段内容占据多个 top-k 名额。解决有两个层面建库脚本开头先删掉storage/index目录全量重建就是全量重建不要试图增量上传接口按文件内容的哈希做去重元数据里存一个doc_hash字段重复的直接跳过。我一般还会在add之前做一次source级别的清理保证一个文件名在索引里只出现一次。全量重建在几千块的规模下只要几十秒为了省这几十秒做增量后面排查重复的代价大得多。5.4 现象中文文档切出来的块读起来断头断尾检索命中率低原因通常在切分正则上。\n\s*\n这个段落分隔符在从 PDF 提取的文本里经常不成立——PDF 抽出来的换行是一行一个\n段与段之间没有空行于是整篇被当成一个超长段落进入按句号拆分的分支块边界就变得很碎。另一种情况是 Word 里手动换行符和段落标记混用同样拆不准。解决办法是先做一次文本规范化把连续三个以上的换行压成两个把单个换行在非列表行的情况下替换成空格再按双换行切。这一步加在load_document返回之前属于归一化不影响切分函数的通用性。改完拿两三份典型文档跑split_text打印前十个块人工读一遍比看任何指标都直接。5.5 现象多人同时提问时偶发返回空结果或 500在内存里维护索引、又在 Web 进程里热更新就会出现这个问题。add的过程中self.mat和self.meta会短暂不一致此时并发的search可能拿到新旧混合的矩阵argsort出来的下标越界或者取到的元数据和向量对不上。解决方式是读写分离建库只走离线脚本服务启动时一次性load索引运行期间只读不写确实需要在线更新就加载到一个新的VectorIndex实例构建完成后用一次原子赋值替换引用不要在原实例上原地追加。如果用了多进程部署每个进程各自持有一份只读索引更新时通过重启或版本号切换来生效别指望共享内存里的对象能安全并发修改。6. 二次开发从“能答”到“答得准”的两个改造点跑通之后接下来能明显提升效果的地方其实不多我一般只动两个。第一个是换掉检索方式把纯向量检索改成向量加关键字的混合召回。纯向量对语义相近但字面不同的表达友好但对专有名词、编号、型号这类精确匹配很弱——问“接口版本 V2.3 的变更”向量可能召回一堆讲版本管理的通用段落反而漏掉真正提到 V2.3 的那一条。做法是在search之外加一路基于倒排的 BM25 或简单的关键词打分两路结果用倒数排名融合def rrf_fuse(vec_hits, kw_hits, k: int 60, topn: int 5): # 倒数排名融合两路各自排名按 1/(krank) 累加 score {} for hits in (vec_hits, kw_hits): for rank, h in enumerate(hits, start1): key (h[source], h[chunk_id]) score[key] score.get(key, 0.0) 1.0 / (k rank) ranked sorted(score.items(), keylambda x: -x[1])[:topn] return [key for key, _ in ranked]k取 60 是常见默认值作用是压平两路排名差异的幅度让位次靠前的结果不至于一家独大topn就是最终送进提示词的条数。融合之后要重新标min_score因为 RRF 的分数尺度完全不同于余弦相似度之前那个 0.25 在这里没有意义阈值判断应该挪到融合之前的两路各自内部。第二个是加一层重排。向量召回取 top-20再用一个小的交叉编码模型对这 20 条和问题逐对打分取前 5 进提示词。代价是延迟增加几百毫秒收益是精度提升通常比换更大的嵌入模型更明显尤其是文档里有大量相似句式的时候。改法很局部retriever.answer里在search之后插一步rerank(hits, question)其余不动。改造项改动位置典型收益主要代价混合召回retriever.py 新增关键词索引专有名词命中率提升明显多一份索引要维护交叉编码重排retriever.py的 answer 前一步排序质量提升延迟增加数百毫秒按标题层级切分splitter.py长文档结构清晰需要解析文档结构改造之后一定要用scripts/eval_retrieval.py跑一遍固定问题集再上线这个脚本我准备了一份三十条问题加标准答案的小样本每次改检索逻辑前后都跑看命中率和答案一致性的变化。凭感觉调参是我早期最常翻的车——改完觉得“好像好一点”上线三天被反馈答错回头根本不知道是哪次改动引入的。从那以后我每次动检索和切分参数都强制先跑一遍固定问题集再合代码改动前后两组数字摆在一起行不行一目了然。希望这套东西能帮你少走几步弯路。本文还有配套的精品资源点击获取