这个代号“rea”其实没什么高深的含义,拆开就是 Retrieval-Enhanced Assistant,我当时起名只用了半秒钟。原因很直接:我手头攒了六百多页的个人资料,分散在两个笔记软件和三个磁盘目录里,查一个项目的截止日期或者某份实验方案的参数,靠记忆搜半天都不一定准。所以我想给自己做一个“检索增强私人助手”:先从我自己的文档库里捞出相关内容,再让大模型基于这些内容回答。目标就一个——别让模型瞎编,也别让我的资料躺在硬盘里吃灰。
如果你手头也有大量文档、笔记、报告、Markdown 知识库,又想让大模型“学会”这些内容,那这个项目会很适合你。即使你完全不懂检索增强,只要有一点点 Python 基础,跟着下面这套思路也能搭出一个能用的本地问答助手。我把它从设计、参数、实现到踩坑全过程拆开讲,大部分结论都是实测得来的,不是文档里的标准答案。
1. 为什么我把方案定成检索增强
1.1 先分清问题:微调在这里不划算
动手之前,很多人都卡在一个问题上:是做微调还是做检索增强?我的结论非常明确——如果目标是“让模型知道我文档里写了什么”,检索增强的成本和效果都远优于微调。
微调本质上是改变模型内部的权重,让它记住某种行为模式或风格。比如我想让模型用更口语化的方式回复,或者模仿某种固定的报告格式,微调是有用的。但我的需求是“模型能准确引用我那几百页资料里的具体内容”,这属于事实型知识的注入。事实型知识最大的特点是更新频繁、数量巨大、又要求准确。今天新增的一份会议纪要,微调流程要走数据清洗、训练、验证、部署,一次好几小时;检索增强只需要把这份文档切块、向量化、写入向量库,下一次问答立刻就能命中。
另外,微调是黑盒,你无法知道模型到底记住没有、记偏没有。检索增强天然可解释:每次回答前,系统把哪些原文片段拿给模型看了,一目了然。从资源角度来看,本地跑一个开源大模型做推理已经很吃内存;微调则对显存有更高要求。两相对比,我毫不犹豫选择了检索增强。下方表格是我当时做的选型对比,基本代表了大多数个人知识库场景的结论:
| 维度 | 微调 | 检索增强 |
|---|---|---|
| 更新成本 | 每次增改都要重新训练 | 重新写入向量库即可 |
| 适合场景 | 学习回答风格、行为范式 | 注入大量事实型文档知识 |
| 可解释性 | 黑盒,难以追溯 | 可查看召回片段与来源 |
| 部署资源 | 需要训练显存和调参周期 | 只需推理模型,负担小 |
| 幻觉控制 | 依赖模型记忆,容易编造 | 可限制模型只按给定资料回答 |
| 适合个人项目 | 重,维护成本高 | 轻量,迭代快 |
一句话总结:如果你的文档是“知识型”的,不是“行为型”的,别折腾微调,直接把检索增强用起来。rea 这个项目本质上就是把这条链路做得轻便、可落地。
1.2 rea的完整链路到底是怎样跑的
先看整体流程,再决定从哪里切入。一个最小可用的检索增强系统,运行一次问答时会依次执行下面这六步:
- 将文档清洗、分块,把每一块转成向量;
- 把向量和原文片段一起存入本地向量集合;
- 用户提出问题后,把问题转成同样的向量;
- 在向量集合里找出最相似的一批候选片段;
- 对候选片段做重排,筛出真正有用的少量上下文;
- 将上下文和问题拼进 Prompt,交给大模型生成答案。
我常用一个类比解释这一步:大模型就像一个刚入职的实习生,你问他“咱们上个月运营数据怎么样”,他不可能凭空知道,因为他没参与过这个项目。但你把相关资料打印好,夹在文件夹里递给他,说“先翻这些材料再回答”,他就能答得八九不离十。检索增强做的事情,就是在每次提问时自动去翻文件夹、挑选最相关的几页纸,然后连问题带资料一起递给大模型。这个过程完全发生在推理阶段,模型权重不动,改的是“它读到的输入材料”。
这六步听上去简单,但每一环都有参数需要调,都有坑要踩。尤其是切分大小、召回条数、重排策略和 Prompt 写法,直接决定最终答案质量。接下来我把各个环节的关键参数和原理拆开讲。
2. 核心细节:这些参数真的会改变结果
2.1 文档切分:chunk_size、overlap 怎么搭配
切分是整个检索增强项目里最容易被低估的地方。很多人随便写一个固定长度切分就入库了,结果检索效果奇差,还以为是模型不行。我实测下来,切分策略对最终效果的影响,至少和选模型五五开。
为什么不能把整篇文档变成一个向量存进去?两个原因。第一,嵌入模型有输入长度上限,超长内容会被截断,等于白算。第二,长文本向量化后会“稀释”语义。一篇三千字的文章,前半段讲方案A,后半段讲方案B,整篇压成一个向量,检索时模型不知道你关心的到底是哪个方案,相似度会被平均掉。这也是我坚持先切分再做向量的根本原因。
切分有两个参数要搭配:chunk_size 和 overlap。我最终用的组合是单块约 500 token,重叠 80 token。为什么是这两个数字?因为块太小,单个片段的信息量不足,比如把一句“系统延迟降低到 200ms”单独切出来,缺少上下文,模型很难判断这句话的背景;块太大,跨主题混合问题就会出现。500 token 对多数技术文档和笔记来说,基本可以覆盖一个完整观点。重叠 80 token 是为了避免把一句关键结论拦腰截断,让片段之间的语义保持连续性。
更讲究一点的做法是按结构切。我自己的文档大多是 Markdown 和纯文本,所以先按“标题层级”切,比如从##、###这种标题处断开;如果某个标题下面的内容还是过长,再按 token 做二次切分。这样做出来的片段,天然对应文档里的一个主题小节,比纯按字符切要规整得多。下面是我用的按 token 切分逻辑,可供参考:
def split_by_tokens(text, tokenizer, chunk_size=500, overlap=80): tokens = tokenizer.encode(text) chunks = [] step = chunk_size - overlap for i in range(0, len(tokens), step): chunk_tokens = tokens[i:i + chunk_size] if len(chunk_tokens) < 50: continue chunks.append(tokenizer.decode(chunk_tokens)) return chunks注意最后那个if len(chunk_tokens) < 50: continue,这是我从踩坑里学到的:末尾剩余几十个 token 的残块,信息量低,还会污染检索结果,直接丢掉更干净。
2.2 嵌入模型:召回上限其实由它决定
切分完成后,每个片段要被转成一个向量。这个“转向量”的模型叫嵌入模型,它决定了检索阶段的上限。为什么这么说?因为重排、Prompt 再怎么优化,都只能从“被召回的候选里”做选择;如果嵌入模型质量太差,真正相关的片段根本没进入候选列表,后面做什么都白搭。
我的选择标准是:本地能跑、支持中文、维度适中。项目早期我用的是一个通用中文嵌入模型,输出向量维度 768,单条文本最长支持 512 token。这个模型的好处是通用性好,普通技术文档、笔记、说明文字都能覆盖。如果语料是特别垂直的领域,比如大量医学、法律或非常专业的术语,通用模型可能对领域词汇不敏感,那就需要换成在该领域语料上训练过的模型,这个只能靠小批量测试判断。
向量化入库的核心操作其实就是循环一句“把文本转成向量然后存起来”。我把核心代码抽象成下面这个样子,方便换模型测试:
from typing import List import numpy as np def embed_texts(texts: List[str], embed_model): return embed_model.encode(texts, normalize_embeddings=True)normalize_embeddings=True这一行要强调一下。向量相似度计算常用余弦相似度,如果所有向量都做了归一化,内积结果就等于余弦相似度,计算更快,数值也更稳定。入库和查询时都做归一化,后续检索直接用矩阵乘法就够。
嵌入模型还有一个隐性影响:向量维度决定内存占用。我算过一笔账,768 维向量用 float32 存储,一条向量约占用 3072 字节,一万条片段大概是 30MB,完全可以在内存里暴力检索。如果你的文档量到十几万块,内存压力变大,就要考虑更专业的向量索引,但个人项目阶段,暴力检索足够用。
2.3 检索与重排:TopK、阈值和交叉编码器
向量入库之后,检索就是最简单的相似度搜索:把问题向量和库里所有向量做内积,按得分排序,取前 K 个。这里有两个关键参数:TopK 和相似度阈值。
我的经验是 TopK 取 8,召回之后再重排取 4。TopK 太小容易漏掉真正相关的片段,尤其当文档库比较大的时候;TopK 太大,把一堆勉强相关的片段一起塞进 Prompt,模型会分不清重点。先召回多个候选,再重排筛选,是比较经典的“粗排 + 精排”结构。重排模型我用的交叉编码器,它不像双塔结构的嵌入模型那样把问题和文档分别编码,而是把“问题 + 文档”拼在一起送入模型,能更精准地判断相关程度,代价是计算慢,没办法直接在整个库里跑。所以流程必须是:先用向量检索粗筛出 8 条,再用重排模型精筛出 4 条。这既照顾了效果,也控制了资源消耗。
相似度阈值据实调整,我的经验值是 0.3。如果问题向量和所有片段向量的最高相似度都低于这个值,就说明资料库里没有相关内容,此时应当直接回复“根据现有资料无法回答”,而不是硬搜一个不相关的片段丢给大模型。这个阈值不能照抄别人的,因为不同嵌入模型产出的分数分布差别很大。我调试时会把每一轮检索的最高得分打印出来,观察一段时间后,就能找到适合自己语料的分界线。
检索加重排的核心逻辑大概是:
def retrieve_and_rerank(query_vec, store, rerank_model, top_k=8, top_n=4): hits = store.search(query_vec, top_k=top_k) reranked = rerank_model.rerank(query_text, hits) # 过滤低分项 filtered = [r for r in reranked if r.score >= threshold] return filtered[:top_n]2.4 Prompt模板:不装懂才能少幻觉
很多人把检索增强做得很好,几步链路都通了,最后发现模型回答时仍然自由发挥。问题大多出在 Prompt 上——检索回来的资料确实放进去了,但模型把资料当成参考消息,而不是唯一依据。
我用的 Prompt 模板非常简单直接:
你是一个只能依据给定资料回答问题的助手。 资料如下: [1] 来源:项目周报.md - 第3章 - 项目进展 内容:xxx [2] 来源:方案设计.md - 第2节 - 技术架构 内容:xxx 问题:xxx 要求: 1. 只依据上面资料回答,不要使用资料以外的知识; 2. 如果资料不足,明确回答“根据现有资料无法回答”; 3. 回答中涉及的关键结论,尽量标注来源编号,例如 [1]。为什么这么写?因为大模型有强烈的“补齐”倾向,如果你不给它一个明确约束,它会默认用自己的训练知识填空。要求它标注来源编号,不仅方便我核对,也在结构上强化了“基于资料”的行为。千万不要嫌这句“无法回答”多余,它是整个幻觉控制的重要防线。我见过太多人搜到一个相似片段就强行回答,最后编出一堆看似合理的内容。
3. 实操过程:从零到能用的rea
3.1 环境准备与最小依赖
搭建环境时,我没有用很重的框架,能少装就少装。Python 版本用的 3.10,先建虚拟环境,避免把系统 Python 搞乱。依赖方面,核心是嵌入模型、大模型推理服务、数值计算库。下面是一组可执行的基础准备命令:
python3 -m venv rea-env source rea-env/bin/activate pip install --upgrade pip pip install numpy大模型的接入方式视硬件而定。如果本机显存充足,可以直接在本地跑开源对话模型;如果机器资源有限,也可以把模型部署在另外一台机器上,通过本地接口调用。rea 项目里我最初就是本地部署,方便调试,模型加载后常驻内存,避免每轮问答都重新加载。
3.2 入库脚本:清洗、切分、向量化一把梭
先讲数据准备。我把散落的 Markdown 和 TXT 文档统一放到一个目录里。入库前做了三步清洗:去掉无意义的重复标题、去除代码块外的多余空行、把明显是临时记录的“TODO”内容清理掉。这一步不能省,脏文本切出来的块本身质量就差,检索效果自然好不了。
清洗完进入切分环节。我按结构优先原则处理:先把文档按 Markdown 标题拆成大段,再对大段调用前面写的按 token 切分函数。每一块都保留了“文档名 + 标题路径 + 原文”三段元数据,后面无论是溯源还是排查问题都用得上。
向量化入库部分,我写了一个类来管理向量和元数据。小规模场景不需要上重型索引,一个简单的矩阵内存检索即可:
class SimpleVectorStore: def __init__(self): self.vectors = [] self.metadata = [] def add(self, embedding, meta): self.vectors.append(embedding) self.metadata.append(meta) def search(self, query_vec, top_k=8): matrix = np.array(self.vectors) scores = matrix @ query_vec idx = np.argsort(-scores)[:top_k] return [(self.metadata[i], float(scores[i])) for i in idx]整个入库脚本的执行流程是:遍历文档目录,读取每个文件,清洗并切分,提取文本块,向量化,然后逐块store.add()。全部跑完大概几分钟。之后如果只是新增一两份文档,不需要全量重建,按文件的修改时间判断增量即可。我的处理方式是维护一个已入库文件清单,只处理新改动过的文件,处理完更新清单。
3.3 问答主逻辑:装一个能跑起来的本地接口
问答服务我封装成一个类,核心思想是把“向量化问题、检索、重排、拼 Prompt、生成答案”串成一条函数调用链。这步的代码不复杂,关键在于用日志把每一步的结果打出来,尤其是检索命中的片段,方便判断问题出在召回阶段还是生成阶段。
class REAService: def __init__(self, store, embed_model, rerank_model, llm): self.store = store self.embed_model = embed_model self.rerank_model = rerank_model self.llm = llm def answer(self, question): q_vec = self.embed_model.encode([question], normalize_embeddings=True)[0] hits = self.store.search(q_vec, top_k=8) reranked = self.rerank_model(question, hits)[:4] if not reranked or reranked[0].score < 0.3: return "根据现有资料无法回答。", [] prompt = build_prompt(question, reranked) reply = self.llm.generate(prompt) return reply, reranked我部署时先做成了命令行轮询,方便快速测试;后来改成轻量 Web 服务,这样浏览器或者局域网里的其他设备也能访问。Web 层其实很薄,核心就是接收问题、调用answer()、返回答案和参考来源。这一步对纯个人使用来说不是必须的,但如果你想把工具分享给团队内部用,Web 接口几乎是必需品。
3.4 效果测试:别只看答没答对
上线前我准备了二十来个测试问题,覆盖四类场景:文档里直接有答案的、需要跨文档归纳的、文档里没有但模型可能瞎编的、以及测试参数记忆的。直接有答案的问题最基础,跨文档归纳更能反映重排质量;而“文档里没有的问题”是用来检查幻觉控制的——如果模型答得言之凿凿,说明 Prompt 或阈值有问题。
我记录了调整前后的对比:只做向量检索、不加重排时,二十个问题里大约一半回答不够精准;加了重排并调整切分策略后,通过率明显提高。最有价值的变化是“拒绝回答”变多了,模型开始老实说“资料里没有”。这其实是个好现象,说明它没有硬编。
测试还暴露一个问题:纯凭主观判断不够稳定。我的做法是给每个测试问题写一个“通过标准”,比如“答案中必须出现项目代号”“必须引用第2章内容”这种硬指标。用硬指标打分,调参时才不会靠感觉。这部分优化看起来不起眼,却是整个项目从玩具走向能用的关键一步。
4. 真实踩坑记录与问题排查
4.1 检索出来一堆相关片段,答案还是错
这是我调参期遇到最多的问题:检索结果明明看起来相关,但生成答案依然出错。后来我把命中片段全部打出来逐条看,发现问题往往出在三处。
第一处是切分不当。如果块切得太大,一个片段里包含两三个主题,重排模型虽然判断它“相关”,但模型真正需要的那句话只占其中一小部分,生成时容易被无关内容干扰。解法是调小 chunk_size,或者在切分时优先按标题语义断开。
第二处是重排缺失。最初我只用嵌入模型做相似度检索,直接取前 4 条进 Prompt,效果不稳定。加上重排后,命中质量明显上一个台阶。重排模型虽然慢,但对 TopK 范围内那几条数据做精排,整体耗时也就几百毫秒,值得加。
第三处是 Prompt 指令太弱。我最早只写了一句“请根据以下资料回答问题”,模型实际回答时还是爱自由发挥。改成“只能依据给定资料回答,资料不足时明确说明无法回答”之后,幻觉明显减少。建议出现答案错误时,先看 Prompt 日志,再看召回片段,按顺序排查,别一上来就怀疑大模型能力不够。
4.2 查询稍长一点就慢得离谱
有一段时间,我的问答延迟高到没法用。排查后发现瓶颈不在大模型推理,而在两个地方:一个是向量化入库时逐条调用嵌入模型,没有做批量处理;另一个是每次查询都重新加载嵌入模型和重排模型,模型加载时间甚至比推理时间还长。
解决方式不复杂:嵌入模型和重排模型在服务启动时只加载一次,常驻内存;向量化文本时把多条文本拼成一个 batch 再调用编码接口,不要用 for 循环一条条编码。这两处优化之后,查询耗时降了一个量级。如果你嵌入的是几百个片段,感受可能不明显;一旦上千条,批量编码能省下数倍时间。
另外,内存暴力检索在万条以下很轻松,但上万条之后就要考虑向量索引了。我用一万条向量做过内存占用估算,768 维 float32 大约 30MB,还处于很舒服的范围;如果到百万条级别,就得切换索引策略。假如你只是个人使用,几千到几万条片段,暴力检索完全扛得住。
4.3 常见问题速查表
我整理了一张速查表,项目里遇到的多数问题都可以照这个思路排查:
| 现象 | 可能原因 | 处理优先级 | 推荐操作 |
|---|---|---|---|
| 检索结果为空 | 相似度阈值过高 / 嵌入模型不支持该语言 | 高 | 降低阈值,检查嵌入模型对中文兼容性 |
| 问题相关但答案错误 | 切分块过大 / Prompt 约束不严 | 高 | 调小 chunk_size,强化“只依据资料”指令 |
| 回答从不拒绝,硬编 | Prompt 缺少“无法回答”约束 | 高 | 在 Prompt 中显式加入拒答指令 |
| 查询耗时过长 | 模型重复加载 / 逐条编码 | 中 | 服务常驻模型,批量编码文本 |
| 跨文档归纳能力差 | 重排缺失 / TopK 太小 | 中 | 增加重排环节,适当调大 TopK |
| 内存占用太高 | 向量维度高、数据量大 | 低 | 采用更高效的向量索引或降维 |
这张表说到底只是一份参考,不同语料、不同模型下表现会略有差异,但排查思路是通用的:先确认召回,再确认重排,最后确认生成。
4.4 我在调试时必用的三个小技巧
第一个技巧是做一个超小的演示文档集。不要一上来就把六百页资料全部入库,而是挑三五篇文章、几十个片段,先跑通链路。小集合问题容易暴露,日志清晰,调整参数后反馈也快。等到小集合稳定了,再全量入库找规模带来的问题。
第二个技巧是把每一轮问答的召回片段完整打印到日志里。QA 系统不像纯检索系统那么直观,你不看模型“到底吃了什么”,很难判断回答偏差来自哪一环。我会在日志里输出每个命中片段的文档名、标题路径、相似度得分和片段摘要。有了这份日志,调参就从“盲猜”变成“对照实验”。
第三个技巧是控制变量,一次只改一个参数。别同时把 chunk_size 调大、把 TopK 调小、又把阈值改了。我走过这样的弯路,最后效果确实变了,但完全说不清是哪一步起作用。正确的做法是先固定其他参数,只改切分,评估一轮;再固定切分,只改重排或阈值,再评估一轮。这样一套流程下来,哪一步有效、哪一步无效,清清楚楚。
关于 rea 的一点后续想法
rea 跑通之后,我用它最多的地方反而是零散的会议记录和调研笔记。以前想找某个方案为什么被否决、当时卡在哪个问题上,全靠翻文件夹;现在直接问一句就能带出来原文位置。我在实际使用里的最大感受是:检索增强的价值不在于模型多聪明,而在于把“查资料”这个动作藏到了问答背后。
后续我计划给它加两个扩展:一个是定时增量索引,文档更新后自动重建相关片段;另一个是把它接到更轻的入口上,比如浏览器插件,让问问题不用专门打开终端。如果你也想做类似的项目,不妨从二十个测试问题、一个干净的文档目录开始。参数都可以调,思路通了,剩下就是体力活。