这是我的“大模型应用开发学习笔记”系列第四篇。前面三篇分别记了API怎么调、Prompt怎么写、Embedding是什么,但说实话,学到第三篇的时候我一直觉得手里有零件,拼不成产品。直到我把这几块零件焊在一起,做出一个能针对自己资料回答问题的小系统——也就是现在大家常说的RAG——才真正感觉“通了”。这篇笔记的主题就是RAG知识库问答里的四个关键环节:文档切片、向量化、向量检索和上下文生成,全部基于我自己踩过的坑和实测过的最小实现来写。
如果你也处于“会调接口、会写Prompt,但不知道怎么做成一个完整应用”的阶段,这篇笔记应该能帮你省掉不少摸索时间。我会尽量把每个环节为什么这么做讲清楚,而不只是甩一段能跑的代码。
1. 为什么学到第四篇,我决定动手写一个RAG
1.1 前三篇笔记留下的问题
学前三篇的时候,我其实是有点挫败的。调大模型接口很容易,一个chat/completions请求就能得到一段不错的回答;写Prompt也不难,无非是把背景、约束、示例说清楚;用Embedding把文本变成向量也很快,几行代码就能算出两句中文之间的相似度。
但问题在于:这些东西单独摆在那里,哪个都不能直接回答“我的私有资料里有没有提到……”“把这份文档和那篇文章里矛盾的地方列出来”这类实际需求。模型没看过我的资料,Prompt写得再好,它也只能拿它自己的知识硬答。Embedding能把文本向量化,但向量化之后呢?谁来帮你把用户的问题变成检索条件?谁来决定哪些片段该被塞进Prompt?这些在初学者教程里往往被一笔带过了,我决定自己把它们串起来。
1.2 RAG是个什么东西
RAG,全称是Retrieval-Augmented Generation,检索增强生成。我的理解特别简单:这就是一场开卷考试。模型不靠背诵来答题,而是在答题前先给他翻一摞参考资料,让他从里面找到依据,再结合自己的语言能力组织成答案。
开卷考试的参考书怎么来,就是这个系统的核心逻辑。整个链路可以拆成两条线:
- 离线构建:把一批文档读进来,切成片段,每一段转成向量,存进向量库。
- 在线问答:拿到用户问题,把问题转成向量,到库里检索最相关的一批片段,然后把“问题+片段”一起交给LLM生成答案。
这两条线里,最容易出问题的地方根本不是模型调用,而是文档切得好不好、检索准不准、上下文组装得合不合理。后面几节我会按这个顺序逐个讲。
1.3 这一篇的目标定义
动手之前我先给自己定了一个明确目标:做一个最小可用的本地问答系统,能够对一批中文Markdown和PDF文档进行问答,回答必须基于资料内容,并且能给出对应的原文位置。不追求做成产品,也不追求跑多大数据量,核心是把链路跑通,并且每一步都能看到中间结果。
这个目标定义很关键。因为定了“能看到中间结果”,我就逼着自己把每个环节都拆开而不是直接套框架。事实证明这个决定帮我躲开了很多黑盒问题——后面排查故障时,如果不是每一环都有日志,估计得抓狂一整天。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最小项目骨架:先搞清楚每个文件在干嘛
2.1 技术选型:为什么不用LangChain,为什么用OpenAI兼容接口
动手之前最纠结的一件事,就是用不用LangChain。当时我的感受是:LangChain的抽象很多,Chain、VectorStore、DocumentLoader一层套一层,好处是入门快,坏处是你很难知道中间到底发生了什么。尤其是我这种想搞清楚RAG原理的人,用它反而容易被各种包装劝退。
所以我决定这一版不依赖任何RAG框架,核心链路自己写。用到的东西只有:
- Python 3.10作为主语言,做文本处理、HTTP调用和简单的数值计算。
- sentence-transformers加载本地Embedding模型,负责把文本转向量。
- numpy做向量相似度计算,Top-K检索先用暴力算一遍,数据量大了再换专用索引。
- pypdf处理PDF文档的文本提取。
- requests直接调用OpenAI兼容格式的LLM接口,不额外引SDK。
简单做了一个对比,帮助自己理清思路:
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| LangChain全流程 | 封装度高,能快速跑通 | 黑盒多,中间环节难排查 | 不合适,学不到东西 |
| 自己写加载/切片/检索/组装 | 每步可控,能看中间结果 | 代码量略大,需要自己处理边界 | 选定这个方案 |
| 直接用商业RAG产品 | 零开发,开箱即用 | 无法学习原理,难以定制 | 不符合我的学习目标 |
LLM接口选择OpenAI兼容格式,是因为这种方式同时兼容云服务和本地部署服务。我在config.py里把base_url和api_key做成了配置项,本地部署时指向本机端口,用云服务时替换成自己的密钥就可以,不用改业务代码。这一点后面联调时省了不少事。
2.2 项目文件结构
整个项目我拆成了七个文件,每个文件只干一件事:
code复制rag_demo/
├── config.py # 所有可调参数
├── loader.py # 读取txt/md/pdf,输出原始文本
├── splitter.py # 文本清洗 + 切片
├── embedder.py # 加载embedding模型,封装向量化
├── vector_store.py # numpy向量库 + 余弦相似度检索
├── llm_client.py # 调用OpenAI兼容格式的LLM接口
└── rag.py # 主流程串联,带日志输出
这个拆分方式是我刻意保持的。每个文件对应链路里的一个环节,出了问题只需要看对应模块。比如检索不准,就单独测vector_store和embedder;回答不像话,就去看rag.py里组装出来的Prompt长什么样。项目规模小,做成一层目录就够,不需要Package那套复杂结构。
2.3 依赖版本里最容易踩的坑
requirements.txt我写死过几个版本,主要目的是避免新版改动带来的意外:
code复制sentence-transformers>=2.7.0,<3.0.0
numpy>=1.24,<2.0.0
pypdf>=4.0,<5.0.0
requests>=2.31.0
这里有两个教训值得单独说。
第一个是sentence-transformers的版本。2.7版本之后才比较好地支持BGE系列的模型使用,但3.x在某些环境里对旧版模型权重处理有变化,所以我把上限定在了3.0之前,减少未知变数。
第二个是numpy 2.x。numpy 2.0在2024年年中出来后,不少第三方库的预编译二进制还没跟上,会遇到一些奇怪的不兼容报错,所以我也锁在了1.x。这不是说新版本不好,而是学习阶段没必要在环境问题上消耗精力,等系统跑通再升级不迟。
requirements之外,我还建议单独准备一个Python虚拟环境。这一步看似多余,实际救过我好几次,因为本地同时存在的其他项目可能会影响依赖版本,虚拟环境可以把这次实验的依赖隔离干净。
2.4 config.py里的参数,先知道它们是什么
config.py是整条链路的“总控台”,我在这里集中放了几个参数,并给每个参数都写了注释:
python复制# config.py
LLM_BASE_URL = "http://127.0.0.1:8000/v1" # OpenAI兼容接口地址
LLM_API_KEY = "EMPTY" # 本地服务通常不校验key
LLM_MODEL_NAME = "qwen2.5-7b" # 具体模型名按实际服务端配置
EMBEDDING_MODEL = "BAAI/bge-small-zh-v1.5" # 本地embedding模型
EMBEDDING_DIM = 512 # 模型实际输出维度,加载后可以覆盖
CHUNK_SIZE = 400 # 单个切片的最大字符数
CHUNK_OVERLAP = 80 # 相邻切片之间的重叠字符数
TOP_K = 5 # 检索返回的片段数量
DOC_DIR = "./docs" # 知识库文档存放目录
这些参数看起来平淡无奇,但后面每一个都被我用实测数据校准过,尤其是CHUNK_SIZE和TOP_K这两个,直接决定了检索和回答的质量天花板。具体的校准过程在下一节里细说。
3. 文档解析与切片:检索质量的源头在这里
3.1 支持txt、md和PDF的加载器
我的知识库里主要是Markdown笔记和PDF报告,所以loader.py先做了最常用的三种格式支持:txt、Markdown和PDF。load_text和load_markdown其实都是读文件,区别只在于Markdown解析时会把代码块和标题单独标记出来,方便后面切片保留结构信息。
PDF是最麻烦的。我用的pypdf简单版代码是这样的:
python复制# loader.py 片段
import pypdf
def load_pdf(path):
reader = pypdf.PdfReader(path)
pages = []
for idx, page in enumerate(reader.pages):
text = page.extract_text()
if text and text.strip():
pages.append(f"\n--- 第{idx + 1}页 ---\n{text}")
return "\n".join(pages)
这个函数看起来很简短,但实际上有个大坑:pypdf提取出的文本是“页面内按阅读顺序切碎的文本块”,不是我们人眼看到的排版结果。表格会被拆成一堆散落的单元格,多栏排版的文档会左右两栏穿插输出,页眉页脚也会混进来。所以加载PDF之后必须做一次清洗,不能直接拿去切片。
还有一个更扎心的情况是扫描版PDF。这种文件本质是图片,pypdf一个字都提取不出来。我的应对方案是先在外面用OCR工具转成文本版PDF再入库。这一步我没有写进代码里,因为在学习阶段,OCR工具链的安装和调优是个独立的大坑,不值得为了一个完整Demo提前卷进来。
3.2 为什么要做文本清洗
很多教程会跳过清洗这一步,直接拿原始文本切片。我的实测结论是:不清洗,检索质量会明显下降,尤其是PDF文档。我用一个真实案例说明:原始PDF里“我们在上\n一节中\n讨论了”这种断行,如果不处理,就会被切进两个不同的chunk里,导致每一段都信息不完整,检索时哪一段都召不回完好的信息。
所以我写了一套贴合中文场景的清洗规则,按优先级做:
- 去掉空行和只有空白字符的行。
- 把行尾的连字符断词合并,例如“inter-\nnet”还原成“internet”。
- 合并中文语境下的断行:如果上一行末尾是汉字、数字或标点结尾,且下一行不是新段落开头,就把两行拼起来。
- 删除常见页眉页脚模式,比如“第X页”“目录”“公司内部资料”这类无意义高频行。
- 统一全文换行符为\n,去掉多余空格。
这套规则不追求完美,目标是让切片输入尽量接近“人眼能顺畅阅读的文本”。清洗完以后我发现一个问题:Markdown文档里的标题原本是信息结构,被清洗成普通文本后,标题和正文之间的层级关系就丢了。这让我决定重新设计切片逻辑,而不是继续用最简单的固定长度切法。
3.3 切片策略对比:固定长度、按段落、带重叠的滑动窗口
我做了三种切片策略的对比实验,数据源是一篇约5000字的Markdown技术报告,用同一个Embedding模型、同一个测试问题,看哪种策略能最稳定地召回到正确片段。
| 策略 | 做法 | 优点 | 缺点 | 实测结论 |
|---|---|---|---|---|
| 固定长度切片 | 每400字一刀切 | 实现简单 | 切断句子和段落,语义不完整 | 召回的片段经常只有半句话,质量很不稳 |
| 按段落切片 | 按空行或标题分段 | 结构完整 | 段落长短差异大,长段可能超出token预算 | 短段落效果好,长段落体验差 |
| 带重叠的滑动窗口 | 每400字切一段,前后重叠80字 | 保留上下文连续性 | 片段有冗余 | 综合效果最好,推荐 |
最终我选了第三种策略:以段落为基本单位,段落短就凑到接近CHUNK_SIZE再切,段落长就按滑动窗口切,同时保留段落边界尽量不被破坏。具体实现里,我会先按二级标题和空行把文档分成“语义块”,再把每个语义块进一步拆成不超过CHUNK_SIZE的片段。
3.4 CHUNK_SIZE和CHUNK_OVERLAP怎么定
这两个参数不是拍脑袋定的。我的设定逻辑是这样的:
中文场景下,一个字符大概对应0.6到1.5个token,具体取决于分词器。主流开源模型在8K上下文窗口下,输入和输出总共8K左右。为了保证输出质量和剩余上下文预算,我一般把资料片段总量控制在2500到3000 token以内。如果每个chunk是400字符、Top-K取5,片段总量大约是400乘以5等于2000字符,折算成token在1200到2000之间,加上Prompt模板和系统指令,整体是安全的。
CHUNK_OVERLAP取80字符,是经验值。太小起不到衔接作用,太大则会引入大量重复信息,浪费token和检索精度。80字符大概是一两句话的长度,可以容纳前一个片段末尾的结论,同时不会造成明显冗余。
还有一个实操建议:切分时检查每个chunk最后一句是否完整。如果最后一个字符停在“我们认”这种半截位置,就把CHUNK_OVERLAP留大一点,或者在切分点处优先寻找最近的句号、问号、感叹号。我甚至专门写了一个函数,在长度上限内往回找最近的标点符号作为切分边界,宁可长度少几十字,也不切在句子中间。这个细节对Embedding的语义保持非常关键。
3.5 保留文档标题层级的小技巧
切片过程中如果完全丢掉标题,检索出来的片段会“没头没尾”。比如chunk里写着“热点转移到了低空经济,多地的产业园区开始布局相关基础设施”,如果没有“政策趋势”这个大标题,模型很难判断这段文字在讨论什么场景。
我的做法是:在切分时把所属的一级标题、二级标题作为前缀拼到每个chunk开头,用分隔符隔开。实际存储的结构是这样一个字典:
python复制{
"content": "## 政策趋势\n热点转移到了低空经济……",
"source": "docs/行业调研.md",
"title": "政策趋势",
"idx": 12
}
这样做有两个好处:一是检索阶段“标题+正文”的向量表达更完整,能提高召回准确率;二是把source和title存下来,回答时可以告诉用户“这个结论来自《行业调研.md》的‘政策趋势’小节”,这一点对知识库问答来说几乎是刚需。
4. Embedding与向量检索:让代码把“相似度”算给你看
4.1 Embedding模型选型:为什么是bge-small-zh-v1.5
Embedding是整个检索环节的底层引擎。选模型时我比较了三个方向:通用英文模型、中文通用模型、中文检索专用模型。
英文模型直接排除,是因为中文分词和表达习惯差异大,英文模型在中文上的相似度判断经常“看不懂”。中文通用模型效果可以,但检索场景下不如检索专用的BGE系列稳定。BGE是北京智源推出的中文检索模型,其中bge-small-zh-v1.5这个版本只有约1亿参数,在CPU上也能跑出不错的速度,对学习项目很友好。
特别值得一提的是BGE模型的一个使用前提:做检索时,给query加一个固定的指令前缀“为这个句子生成表示以用于检索相关文章:”,而给文档不加。这个细节极易被忽视,我一开始没加,检索准确率明显下降,加了之后立竿见影。原因在于BGE训练时就用了这种“不对称”的方式,query侧和document侧的表示分布有差异,必须按这个格式输入才能对齐。
python复制# embedder.py 片段
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
def embed_query(text: str) -> list:
text = "为这个句子生成表示以用于检索相关文章:" + text
return model.encode(text, normalize_embeddings=True).tolist()
def embed_doc(text: str) -> list:
return model.encode(text, normalize_embeddings=True).tolist()
这里我特意把normalize_embeddings设为True,原因下一节会讲。
4.2 向量检索:先看暴力检索,再谈优化
在引入FAISS、Milvus这些专业向量库之前,我建议至少手写一遍暴力检索。原因很简单:只有亲手算过相似度,你才知道向量检索的本质是一次最近邻搜索,而不是什么黑魔法。
bge-small-zh-v1.5输出的向量维度是512维。假设库里已经存了1000个chunk,每个chunk是一个512维浮点向量,那整个索引就是一张1000乘512的矩阵。查询时把问题向量和每一行做内积,找出最大的K个即可。
用numpy实现暴力检索其实就十几行:
python复制# vector_store.py 片段
import numpy as np
class VectorStore:
def __init__(self):
self.vectors = []
self.metadata = []
def add(self, vector, meta):
self.vectors.append(vector)
self.metadata.append(meta)
def search(self, query_vector, top_k=5):
if not self.vectors:
return []
matrix = np.array(self.vectors) # shape: (N, D)
scores = matrix @ np.array(query_vector) # 因为向量已归一化,内积=余弦相似度
top_ids = np.argsort(scores)[-top_k:][::-1]
return [(self.metadata[i], float(scores[i])) for i in top_ids]
代码里的内积等于余弦相似度,这个成立的唯一前提是向量已经归一化。所以我前面才在embedder里设置normalize_embeddings=True。归一化让模长变成1,余弦相似度的计算就简化为点积,省掉了每次算模长的开销,这对后面数据量增大时的优化非常关键。
注意,argsort默认从小到大排序,所以要取最后K个再反转,这是新手最容易写错的一行。真写反了,返回的是最不相似的片段。
4.3 Top-K到底该设多少
TOP_K这个参数被很多人忽略,其实它对回答质量影响非常大。我做了一组简单的对比实验,问题是“该公司今年在研发费用上的投入趋势如何?”。
| TOP_K | 召回片段情况 | 生成效果 |
|---|---|---|
| 1 | 只召回一段 | 信息太少,回答干瘪,经常漏关键细节 |
| 3 | 召回到相关段落 | 回答基本完整,偶尔会漏不显眼的细节 |
| 5 | 相关段落+少量沾边段落 | 回答完整,但需要Prompt约束模型忽略不相关内容 |
| 8 | 大量沾边段落 | token占用大,模型容易被次要信息带偏,反而出现幻觉 |
最终的结论是:不是召回越多越好。K值过大,向量检索阶段就会混入相似但不相关的片段,LLM的注意力会被这些干扰项分散。我最终把TOP_K固定为5,后续如果再优化,我会做重排序而不是盲目增大K值。
4.4 数据量变大之后怎么办
暴力检索在几千到几万条向量时,速度完全够用,但到了十万条以上,每问一次都全量算一遍就会开始拖慢响应。这一步我现在还没遇到瓶颈,但演进方向是明确的:用FAISS建IVF索引,或者用Milvus这类服务化向量库。切换到这些方案时,前面归一化和点积的设计不会浪费,因为它们的核心也是向量距离计算。
5. 上下文组装与生成:回答质量的最后一步
5.1 Prompt模板:把“开卷考试”的规则交代清楚
检索做得再好,最后生成环节如果Prompt模板稀烂,模型照样胡说。我给LLM的Prompt分了几个必要模块:角色定位、任务要求、参考资料、回答约束。
code复制系统提示词:
你是一个严格基于资料回答问题的助手。
如果参考资料中有相关信息,请基于资料回答,并在句末标注参考来源,格式为[来源文件名-序号]。
如果参考资料中没有相关信息,请直接回答“根据现有资料,我无法回答这个问题”,不要编造内容。
用户请求:
【参考资料】
{context}
【问题】
{question}
这个模板里没有任何花哨的措辞,但三条约束各有用途:“严格基于资料”从根上压幻觉;“标注来源”让我能追踪回答是否有据;“无法回答就明说”是防止模型在资料空白时强行编一个看起来很合理的答案。
我强烈建议你在模板里把“直接回答无法回答”写进去。实测中,不写这句话,模型在资料不足时也会硬答,写了之后模型的表现明显克制很多。
5.2 token预算怎么算,才不会把上下文塞爆
组装上下文时,要控制总量。我的计算方法是:
- 假设LLM上下文窗口是8192 token。
- 预留2000 token给模型生成回答,并预留512 token给Prompt固定模板。
- 可用于资料片段的预算大约还有5600 token。
- 每个chunk约400字符,折算约240到600 token(看文本密度)。
- TOP_K取5时,资料片段总量约1200到3000 token,完全在预算内。
这个预算算法帮我挡住了另一个坑:如果把一个超大文档段落原样塞进Prompt,很可能超长报错,或者模型只记住开头和结尾而忽略中间的关键信息。所以组装函数里一定要写一个简单的截断逻辑:从最高分片段开始往上下文里填,填到预算上限就停止,而不是无脑拼接。
5.3 按相关度降序填充,控制上下文质量顺序
上下文的质量比数量重要,顺序也有讲究。我沿用了“从最相关到次相关”的排序方式填充资料,让模型第一眼看到的就是最贴合问题的内容。实测中,这比“按文档顺序排列片段”更容易让模型抓住重点。
每次调用前,我还会把最终发送给LLM的实际Prompt打印到日志里。这个习惯几乎值回票价:很多看似模型变笨的问题,其实是片段没被正确填进模板,或者模板变量替换出bug。先把发给模型的内容原样检查一遍,往往能找到真正的问题。
5.4 流式输出与交互体感
Demo阶段可以不做流式输出,但既然要在命令行里和这个系统对话,我就顺手给llm_client加了stream参数。OpenAI兼容接口在stream模式下会连续返回content delta片段,我按行打印到终端,模型回答一个字一个字蹦出来,交互体感会好很多。
不过流式模式引入了一个小麻烦:如果总输出不稳定,回答里偶尔会出现截断的引用标注,比如只显示“[文”这种半截格式。我目前处理很简单:在Prompt里要求引用标注必须成对出现,并在后处理时用正则把不完整的引用标注剔除。后续如果做多轮对话,这个问题需要更系统的方案。
6. 联调时的真实翻车现场盘点
6.1 向量维度不一致:换了模型忘删旧库
联调第一天我就翻了个大车。一开始我用了一个384维的Embedding模型,跑通了全流程。后来想换个效果更好的模型,结果新的输出维度是512维,向量库检索时报错说矩阵形状对不上。
这类问题在向量检索里极其常见。我的解决方案是:在vector_store每次启动时记录当前模型的名称和维度,发现与config不一致就提醒重新构建索引。不要相信任何人说的“直接更新模型就行”,向量库必须和Embedding模型完全绑定,换了模型就必须重建索引。
6.2 中文乱码:源头在文件读取,不在模型
Windows环境(或者某些默认编码环境下)读取文本文件容易出乱码。第一次发现在Markdown文件里所有中文变成“鍣ㄥ櫒”之类的内容,我第一反应是Embedding出问题了,查了半天才发现只是文件编码没指定。
解决办法是loader里所有文件读取统一加一个参数:
python复制with open(path, "r", encoding="utf-8") as f:
text = f.read()
还有一个小风险点:有些PDF从Windows生成,字符提取出来是GB编码但pypdf返回的字符串本身是Unicode,一般不会乱码,但如果PDF本身字体映射异常,个别字符会变成方块。这种情况无法靠代码修复,只能做字符过滤,把非法Unicode统一替换为空。
6.3 LLM接口地址拼错,导致500错误
OpenAI兼容接口的地址是有固定格式的,一般是{base_url}/chat/completions,base_url里已经带了/v1路径。我第一次配置时base_url写的是http://127.0.0.1:8000,结果请求发到/chat/completions,服务端返回404,排查了半天才发现少了/v1。
给后来的自己提个醒:配置项里写完整路径很难错,直接写“http://127.0.0.1:8000/v1”就省得拼接了。这个坑虽然简单,但非常容易因为“看着像对了”而被忽略。
6.4 检索片段看似相关但回答很烂:检查提示词里的上下文顺序
有一次用户问“推荐系统如何做冷启动”,检索出来的片段明明都很相关,但回答却答得乱七八糟,一会说协同过滤,一会说物品特征,逻辑很散。我打印了实际Prompt才发现,所有片段是按“文档顺序”排的,最相关的一段被排在了中间,模型在长上下文里没把它当成主线。
后来我把组装逻辑改成按相似度分数降序排列,同时把不相关但分数较高的片段从Prompt里剔除,回答质量立刻提升。这个问题值得多啰嗦一句:RAG不只有“检索准不准”这一个指标,“按什么顺序喂给模型”同样重要,模型对上下文头部和尾部的注意力会天然更强。
6.5 仍然出现幻觉:引用标注不是万能的
我遇到过一种比较隐蔽的情况:参考资料里确实有相关内容,但内容很模糊,模型还是顺着自己已有的知识补全了细节,造成看起来有依据的幻觉。比如资料里只写了“研发投入逐年增长”,模型却把“从1.2亿元增长到1.8亿元”这样的具体数字给编了出来。
解决这个问题的方向不只是Prompt,而是从来源控制:要求模型只能引用资料中出现的数字与事实,并加上“如果资料里没有提到的具体数值,请注明未提及”。同时,我会把回答里的引用标注映射回原始chunk,人工随机抽查,看标注对应的内容是否真的支持回答的说法。这套“抽查制”虽然朴素,但能有效地让幻觉暴露出来。
6.6 日志设计:每个环节都要能单独看
整个联调过程让我最受益的工程习惯,是在每个模块的入口和出口都打一行关键日志。比如加载PDF后打印页数、清洗后打印字符数、切片后打印chunk数量和平均长度、检索后打印命中的前三个片段分数、调用LLM前打印Prompt前200字。这些日志看着啰嗦,但排查问题的时候,每个日志行都相当于一次定位,能直接告诉你哪个环节出了问题,而不是靠猜。
7. 再往后我打算怎么演进
这个最小系统跑通之后,我已经能回答“基于我的文档的问题”了,但离好用还有一段距离。接下来我计划按这几个方向依次演进:
第一是加重排序。目前TOP_K取5,这里面可能混入一些“词面相似但语义并不精确”的内容。加一层Cross-Encoder重排序器,把向量检索召回的50条再精排成5条,预期能明显提升最终回答准确率。
第二是混合检索。向量检索对字面匹配不敏感,如果用户问的是“研发投入”而我库里写的是“研发费用”,向量相似度大概率能够兜底,但还不够稳定。在向量检索之前加一个BM25关键词检索,把两种结果融合起来,可以兼顾语义和字面两种匹配。
第三是文档增量更新。现在的实现每次启动都要把docs目录全部重新切片、重新embedding,数据量大了之后效率太低。后面打算引入一个简单的哈希校验,只处理新增和变动的文件。
第四是评估集建设。等系统稳定了,我会准备一组“问题-期望来源”对,每次改完切分逻辑或Prompt模板以后,跑一遍评估集,看召回准确率和引用准确率有没有下降。没有评估集的话,所有优化都在裸奔,很多时候看起来有效果,其实只是个案运气好。
这次把RAG整个串下来之后,我最大的体会是:RAG的技术栈并不复杂,难在每个环节都有隐蔽的细节,而这些细节只能靠“亲手跑通+出问题时拆开排查”来积累。网上教程通常只讲某一个环节,或者只给你整个链路的高级封装,但真正遇到问题的时候,能救你的还是你对每个环节内部机制的理解。建议你拿到我这套最小实现后,故意去改坏几个参数,看回答质量怎么变化。这种“破坏性实验”是理解系统行为最快的方式,比照着教程抄一遍有效得多。
