1. 从零搭建AI工程能力:为什么我劝你别一上来就调包
这两年AI应用开发的门槛肉眼可见地降低了,一个刚入门的开发者,装好环境、拿到API Key,半天时间就能跑出一个能对话的Demo。但我在带团队和做技术评审的过程中发现一个很普遍的现象:很多人能跑通Demo,却说不清楚一次推理请求背后到底发生了什么,模型输出的Token是怎么被切分和还原的,向量检索的相似度到底是怎么算出来的,一个RAG系统里检索环节和生成环节各自该为最终效果负多少责任。这种“会用不会修”的状态,在项目小的时候问题不大,一旦线上效果不稳定、成本失控、延迟飙升,就完全抓瞎了。
ai-engineering-from-scratch这个标题,在我看来指向的正是这件事:不依赖现成的高级封装,从最基础的组件开始,把AI工程里那些被框架藏起来的关键环节亲手实现一遍。它解决的不是“怎么快速做个AI应用”,而是“怎么真正理解并掌控一个AI应用”。适合谁来参考?我认为有三类人最该认真走一遍这条路:一是刚转行做AI应用、只会调API的开发者;二是需要做技术选型和成本评估的架构师;三是想搞清楚大模型应用底层原理、但被各种框架文档绕晕的在校学生或自学者。
这篇文章我会按照我自己实际走过一遍的路径来展开,从整体设计思路,到核心模块的拆解,再到完整的实操流程和踩坑记录。所有代码和参数我都会给出可复现的版本,涉及取舍的地方我会说清楚为什么这么选。需要提前说明的是,文中涉及的具体模型和工具只是示例,你可以根据自己的环境替换,核心思路是通用的。
2. 整体设计与思路拆解
2.1 为什么选择“从零实现”而不是“直接用框架”
现在主流的AI应用开发框架已经非常成熟,几行代码就能搭起一个带检索的问答系统。那为什么还要从零做一遍?我的理由很直接:框架帮你做的每一个决策,你都得知道它替你做了什么,否则出了问题你连排查的方向都没有。
举个具体的例子。你用某个框架做RAG,检索回来的文档质量很差,模型回答开始胡编。这时候你要判断问题出在哪:是文本切分粒度不对,导致一个完整的语义块被切断了?是Embedding模型对中文语义的区分度不够?是相似度计算用了余弦相似度但向量没归一化?还是召回数量设得太少,关键信息根本没进上下文?这些问题,如果你亲手实现过切分、向量化、相似度计算和上下文拼装这几个环节,排查起来就是几分钟的事;如果全靠框架黑盒,你只能靠猜和试。
从零实现还有一个隐性收益:成本感知。当你自己算过一次Token数量、自己实现过一次批处理调用,你会对“这个功能大概要花多少钱”有一个直觉。我见过太多团队,功能上线前没人算过账,上线后账单出来才傻眼。亲手实现一遍,你会自然地去关注输入长度、输出长度、调用次数这些直接影响成本的变量。
当然,从零实现不等于所有东西都自己造。我的原则是:核心逻辑自己写,基础设施用成熟的。比如HTTP请求、JSON解析、向量存储的持久化,这些没必要重复造轮子;但文本切分策略、Prompt组装逻辑、检索排序规则、上下文窗口管理,这些直接决定效果的部分,必须自己掌控。
2.2 整体架构:把AI应用拆成可独立验证的模块
我习惯把一个典型的AI应用拆成五个层次,每一层都可以单独测试和替换:
| 层次 | 职责 | 从零实现的关键点 |
|---|---|---|
| 输入处理层 | 接收用户输入,做清洗和预处理 | 输入长度校验、敏感内容过滤、多轮对话历史管理 |
| 检索层 | 从知识库中找到相关内容 | 文本切分、向量化、相似度计算、召回排序 |
| 上下文组装层 | 把检索结果和历史对话拼成Prompt | Token预算分配、截断策略、模板管理 |
| 模型调用层 | 与模型服务交互 | 请求构造、重试机制、流式处理、错误处理 |
| 输出处理层 | 解析模型输出,做后处理 | 结构化解析、引用标注、格式校验 |
这个分层的好处是,每一层都可以独立写单元测试。比如检索层,你可以准备一组问题和预期召回结果,单独验证召回率;上下文组装层,你可以给定固定的检索结果,验证拼出来的Prompt是否符合预期。这种可测试性,是框架黑盒给不了的。
我在实际项目里会先把这个骨架搭起来,每一层先用最简单的实现跑通,然后再逐层优化。比如检索层,第一版可以用最简单的按字符数切分加余弦相似度,跑通之后再换成按语义切分加混合检索。这种渐进式的做法,比一上来就追求最优方案要靠谱得多,因为你能清楚地知道每一次优化带来了多少提升。
2.3 技术选型的几个关键决策
在从零实现的过程中,有几个选型决策会直接影响后续的开发体验和最终效果,我逐个说一下我的考量和理由。
文本切分策略:这是最容易被忽视但影响最大的环节。我试过按固定字符数切、按句子切、按段落切、按语义切四种方式。固定字符数切最简单,但会把完整的句子甚至词语切断,导致检索到的片段语义不完整。按句子切效果好一些,但遇到长句子还是会出问题。我最终采用的是递归切分:先按段落切,如果段落超过阈值再按句子切,句子还超再按字符切,同时保留一定的重叠区域。重叠区域的作用是防止关键信息刚好落在切分边界上被割裂。
向量化模型选择:这个没有绝对的最优解,要看你的语料特点。我的经验是,中文场景下,通用Embedding模型在短文本上的表现差异不大,但在长文本和专业领域文本上差异明显。建议的做法是:准备一批你业务场景下的真实问题,用几个候选模型分别做检索,人工评估召回质量,选效果最好的那个。不要只看榜单分数,榜单和你的实际语料往往不是一回事。
相似度计算方式:余弦相似度和点积是最常用的两种。如果向量已经归一化,两者等价。我倾向于显式做归一化然后用点积,因为点积的计算效率更高,而且归一化之后数值范围更可控。欧氏距离也可以用,但对高维向量来说,余弦相似度更符合语义相似度的直觉。
上下文窗口管理:这是成本控制的核心。我的做法是给检索结果、对话历史、系统提示各分配一个Token预算,超出预算时按优先级截断。检索结果优先保留相似度高的,对话历史优先保留最近的,系统提示一般不动。这个预算分配需要根据实际效果反复调整,没有标准答案。
3. 核心细节解析与实操要点
3.1 文本切分:递归切分的实现与参数调优
文本切分是检索质量的地基。我见过太多项目,检索效果差,排查到最后发现是切分粒度不对。这里我把递归切分的实现逻辑拆开讲。
核心思路是维护一个分隔符列表,按优先级从高到低尝试切分。比如["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],先按段落切,段落还是太大就按换行切,再大就按句号切,以此类推,最后按字符切。每次切分后检查片段大小,如果小于阈值就尝试和相邻片段合并,如果大于阈值就继续用下一级分隔符切。
def recursive_split(text, separators, max_size, overlap): # 找到第一个能切分且切分后片段合理的分隔符 for sep in separators: if sep == "": # 最后一级:按字符硬切 chunks = [text[i:i+max_size] for i in range(0, len(text), max_size - overlap)] return chunks if sep in text: parts = text.split(sep) # 合并过小的片段,切分过大的片段 merged = merge_and_split(parts, sep, max_size, overlap) return merged return [text]参数调优方面,max_size和overlap是两个关键参数。max_size我一般设在300到500个字符之间,具体取决于你的Embedding模型的最佳输入长度。很多模型在256到512个Token之间表现最好,超过之后语义会被稀释。overlap我一般设在max_size的10%到20%,太小起不到防割裂的作用,太大则会造成检索结果冗余,浪费上下文预算。
注意:切分的时候要尽量保持语义完整性。我踩过的一个坑是,按标点切分时把代码块、表格、列表这些结构化内容切碎了,导致检索到的片段完全没法用。后来我在切分前先识别这些结构,把它们作为不可分割的单元处理。
还有一个细节是特殊字符的处理。中文文本里经常混着英文、数字、标点,如果分隔符列表里只有中文标点,遇到英文段落就会退化到按字符切。我的做法是在分隔符列表里同时包含中英文标点,并且把连续空白字符也作为一个分隔符。
3.2 向量化与相似度计算:从原理到代码
向量化的本质是把文本映射到一个高维空间里的点,语义相近的文本在这个空间里的距离也相近。理解这一点,很多问题就清楚了。比如为什么检索不到相关内容?可能是你的文本和查询在这个空间里距离就是远,这时候换模型或者加关键词增强比调参有用。
相似度计算我推荐用归一化后的点积。归一化就是把向量除以它的模长,让所有向量的长度都为1。这样点积的结果就在-1到1之间,1表示完全同向,0表示正交,-1表示完全反向。对于语义相似度来说,我们一般只关心正值部分。
import numpy as np def normalize(vec): norm = np.linalg.norm(vec) if norm == 0: return vec return vec / norm def cosine_similarity(a, b): return np.dot(normalize(a), normalize(b)) def batch_similarity(query_vec, doc_vecs): query_norm = normalize(query_vec) doc_norms = np.array([normalize(v) for v in doc_vecs]) return np.dot(doc_norms, query_norm)批量计算的时候,把文档向量预先归一化好存起来,查询时只需要归一化查询向量,然后做一次矩阵乘法,效率比逐个计算高得多。我实测过,一万条文档的检索,批量计算比循环计算快两个数量级。
提示:如果你的向量维度很高(比如1536维),注意浮点数精度问题。我遇到过归一化之后模长不是精确的1,导致相似度出现微小偏差。解决办法是用float64存储向量,计算时再转float32。
还有一个容易被忽视的点是向量存储的索引结构。如果文档数量在几千条以内,暴力计算完全够用。超过一万条,就要考虑用近似最近邻算法了。我一般用HNSW或者IVF,前者召回率高但内存占用大,后者内存友好但需要调参。这个选择取决于你的数据规模和延迟要求。
3.3 Prompt组装:Token预算分配与截断策略
Prompt组装是连接检索和生成的桥梁,也是最容易出问题的地方。核心矛盾是:你希望把最相关的信息都塞进上下文,但上下文窗口是有限的,而且塞得越多成本越高、延迟越大。
我的做法是给每个部分分配固定的Token预算,然后按优先级填充。一个典型的分配方案是:系统提示占10%,对话历史占30%,检索结果占50%,剩余10%留给模型输出。这个比例不是固定的,要根据你的场景调整。如果是多轮对话为主,历史占比要提高;如果是知识问答为主,检索结果占比要提高。
截断策略上,检索结果按相似度从高到低填充,填满预算为止。对话历史按时间从近到远填充,同样填满为止。这里有个技巧:不要简单地把超出预算的内容丢掉,而是尝试压缩。比如对话历史可以只保留用户问题和模型回答的摘要,而不是完整原文。我试过用模型自己来压缩历史,效果不错,但会增加一次调用成本,需要权衡。
def assemble_prompt(system_prompt, history, retrieved_docs, token_budget): # 按优先级分配预算 system_budget = int(token_budget * 0.1) history_budget = int(token_budget * 0.3) docs_budget = int(token_budget * 0.5) # 系统提示一般不会超,直接放 parts = [truncate(system_prompt, system_budget)] # 检索结果按相似度排序填充 docs_text = "" for doc in sorted(retrieved_docs, key=lambda x: x.score, reverse=True): if count_tokens(docs_text + doc.text) > docs_budget: break docs_text += doc.text + "\n" parts.append(docs_text) # 对话历史从近到远填充 history_text = "" for turn in reversed(history): if count_tokens(history_text + turn.text) > history_budget: break history_text = turn.text + "\n" + history_text parts.append(history_text) return "\n".join(parts)注意:Token计数不要用字符数除以2这种粗略估算,不同模型的分词器差异很大。中文一般一个字符对应1到2个Token,英文一个单词对应1到2个Token,代码和特殊符号的Token密度更高。建议直接用对应模型的分词器来精确计数,或者至少用一个接近的估算方式。
3.4 模型调用:重试、流式与错误处理
模型调用看起来简单,但生产环境里大部分稳定性问题都出在这一层。我总结了几个必须处理的点。
超时与重试:模型服务的响应时间波动很大,尤其是高峰期。我一般设置连接超时5秒,读取超时60秒。重试策略用指数退避,第一次重试等1秒,第二次等2秒,第三次等4秒,最多重试3次。注意不要对所有错误都重试,像参数错误、认证失败这种重试也没用,只对超时和5xx错误重试。
流式处理:如果产品需要实时显示生成内容,必须用流式接口。流式处理的坑在于,你需要自己处理数据块的拼接和边界情况。比如一个多字节字符可能被切分到两个数据块里,直接按块解码会出现乱码。解决办法是用一个缓冲区,每次收到数据块先追加到缓冲区,然后尝试解码,如果解码失败就等下一个块。
async def stream_chat(messages, buffer_size=1024): buffer = "" async for chunk in call_model_stream(messages): buffer += chunk # 尝试找到完整的行或JSON对象 while True: line, sep, rest = buffer.partition("\n") if not sep: break if line.strip(): try: data = json.loads(line) yield data.get("content", "") except json.JSONDecodeError: pass buffer = rest错误分类处理:我把错误分成三类。第一类是客户端错误,比如参数不合法、内容被拦截,这类错误直接返回给用户,不要重试。第二类是服务端错误,比如超时、限流、内部错误,这类错误重试。第三类是网络错误,比如连接断开,这类错误也要重试,但要注意幂等性,避免重复计费。
提示:重试的时候要注意请求的幂等性。如果一次请求已经产生了费用但响应丢失,重试会导致重复计费。我的做法是在请求头里带一个唯一ID,服务端如果支持去重就用,不支持的话就在业务层做补偿。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我假设你用的是Python环境,版本3.9以上。核心依赖其实很少,我刻意保持精简,避免引入不必要的框架。
pip install numpy requests tiktokennumpy用于向量计算,requests用于HTTP调用,tiktoken用于Token计数。如果你要用本地的Embedding模型,还需要装sentence-transformers和torch,但那是另一个话题了。这里我以调用远程API为例,因为这样最容易复现。
目录结构我建议这样组织:
ai-engineering-from-scratch/ ├── config.py # 配置管理 ├── splitter.py # 文本切分 ├── embedder.py # 向量化 ├── retriever.py # 检索 ├── assembler.py # Prompt组装 ├── client.py # 模型调用 ├── pipeline.py # 串联流程 └── tests/ # 单元测试这种按职责分文件的方式,好处是每个模块都可以单独测试。比如你想验证切分效果,直接跑splitter.py就行,不需要启动整个流程。
配置管理我建议用一个简单的类来集中管理,不要散落在各个文件里。需要管理的配置包括:API地址、API Key、模型名称、超时时间、重试次数、切分参数、检索参数、Token预算等。把这些集中在一个地方,调参的时候方便很多。
class Config: api_base = "https://your-api-endpoint/v1" api_key = "your-key" chat_model = "your-chat-model" embed_model = "your-embed-model" timeout = 60 max_retries = 3 chunk_size = 400 chunk_overlap = 80 top_k = 5 token_budget = 40004.2 知识库构建:从原始文档到向量索引
知识库构建的完整流程是:读取原始文档、清洗、切分、向量化、存储索引。我逐个环节说。
读取与清洗:原始文档可能是txt、md、pdf、html等各种格式。我建议先把所有格式统一转成纯文本,转换过程中注意保留段落结构。清洗主要是去掉多余的空白、页眉页脚、乱码字符。这里有个经验:不要过度清洗,有些看起来像噪声的内容可能包含关键信息。我一般只做最基本的清洗,把明显无意义的字符去掉就行。
切分:用前面讲的递归切分,参数根据你的文档特点调整。技术文档可以切大一点,因为上下文依赖强;FAQ类文档可以切小一点,因为每个问答相对独立。切分完之后,我建议人工抽查一批片段,看看有没有明显的语义割裂。
向量化:批量调用Embedding接口,注意控制并发和批次大小。批次太大会超时,太小效率低。我一般用16到32条一批,并发数控制在5到10。向量化完成后,把向量和对应的文本、元数据一起存起来。元数据包括来源文档、位置、切分序号等,方便后续追溯。
def build_index(documents, config): all_chunks = [] for doc in documents: chunks = recursive_split(doc.text, SEPARATORS, config.chunk_size, config.chunk_overlap) for i, chunk in enumerate(chunks): all_chunks.append({ "text": chunk, "source": doc.source, "index": i }) # 批量向量化 vectors = [] for i in range(0, len(all_chunks), 32): batch = all_chunks[i:i+32] batch_vectors = embed_batch([c["text"] for c in batch], config) vectors.extend(batch_vectors) # 归一化后存储 for chunk, vec in zip(all_chunks, vectors): chunk["vector"] = normalize(vec) return all_chunks存储:小规模数据直接存成JSON或者用numpy的npz格式就行。大规模数据建议用专门的向量数据库。我个人的经验是,一万条以下用文件存储完全够用,加载到内存也就几十MB,检索延迟在毫秒级。超过这个量级再考虑上数据库。
4.3 检索流程:从查询到召回结果
检索流程是:查询向量化、相似度计算、排序、返回Top K。看起来简单,但有几个细节决定效果。
查询改写:用户的原始查询往往很短,信息量不足。我一般会做一步查询改写,把用户的问题扩展成更适合检索的形式。最简单的做法是用模型把用户问题改写成几个相关的检索词,复杂一点的做法是做多路召回,每路用不同的改写策略,最后合并结果。
相似度计算与排序:前面说了用归一化点积。排序的时候,我建议不要只看相似度分数,还要考虑其他因素。比如文档的新旧程度、来源权威性、是否被多次引用等。这些因素可以通过加权的方式融合到最终排序分数里。
def retrieve(query, index, config, top_k=5): query_vec = normalize(embed(query, config)) scored = [] for chunk in index: score = np.dot(chunk["vector"], query_vec) # 可以在这里融合其他信号 scored.append((score, chunk)) scored.sort(key=lambda x: x[0], reverse=True) return [chunk for score, chunk in scored[:top_k]]召回数量:top_k设多少合适?我的经验是,先设大一点,比如10到20,然后在Prompt组装阶段按预算截断。这样做的原因是,检索阶段多召回一些成本很低,但漏掉了关键信息代价很大。我一般设top_k=10,然后在组装时按相似度取前5条。
注意:如果检索结果里有很多高度相似的片段,说明你的切分可能有问题,或者文档本身有重复内容。这时候要做去重,否则会浪费上下文预算。去重可以用文本相似度,也可以用向量相似度,我一般用向量相似度,阈值设在0.95左右。
4.4 完整问答流程串联
把前面几个模块串起来,就是一个完整的问答流程。我用一个函数来展示:
def answer(question, index, history, config): # 1. 检索 retrieved = retrieve(question, index, config, top_k=config.top_k) # 2. 组装Prompt prompt = assemble_prompt( SYSTEM_PROMPT, history, retrieved, config.token_budget ) # 3. 调用模型 response = call_model(prompt, config) # 4. 后处理 answer_text = extract_answer(response) citations = extract_citations(response, retrieved) return { "answer": answer_text, "citations": citations, "retrieved": retrieved }这个流程里,我特别想强调引用标注这个环节。让模型在回答时标注信息来源,一方面方便用户核实,另一方面也方便你排查问题。如果模型引用了错误的来源,说明检索环节有问题;如果模型没有引用来源就回答,说明Prompt里对引用的要求不够明确。
实现引用标注的做法是在Prompt里给每个检索片段编号,要求模型在回答时用编号标注。然后在后处理阶段解析编号,映射回原始文档。这个做法我实测下来很有效,能显著降低模型胡编的概率,因为模型知道它的回答会被追溯。
5. 常见问题与排查技巧实录
5.1 检索效果差的排查思路
检索效果差是最常见的问题,我整理了一个排查清单,按可能性从高到低排列。
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 切分粒度 | 人工查看切分后的片段 | 片段太短语义不完整,或太长语义被稀释 |
| Embedding模型 | 用几个已知相似的文本测试相似度 | 模型对中文或专业领域区分度不够 |
| 向量归一化 | 检查向量模长是否接近1 | 忘记归一化导致相似度计算错误 |
| 查询改写 | 对比原始查询和改写后的查询 | 改写丢失了关键信息 |
| 召回数量 | 增大top_k看是否包含正确答案 | top_k太小,正确答案没被召回 |
| 相似度阈值 | 检查Top结果的分数分布 | 阈值设太高过滤掉了相关内容 |
我遇到最多的情况是切分粒度问题。有一次一个项目的检索效果怎么调都不行,最后发现是切分的时候把表格切碎了,而用户的问题恰好需要表格里的信息。解决办法是把表格作为整体不切分,如果表格太大就按行切,但保留表头。
还有一个隐蔽的问题是向量维度不匹配。如果你换了Embedding模型但没重新构建索引,查询向量和文档向量维度不一样,相似度计算会直接报错或者得到无意义的结果。这个问题的排查方法是检查两个向量的shape是否一致。
5.2 模型输出不稳定的应对策略
模型输出不稳定表现为:同样的输入,有时候回答很好,有时候胡编乱造。这个问题没有一劳永逸的解决办法,但可以通过几个手段降低概率。
降低温度参数:温度控制输出的随机性,设成0或者接近0的值,输出会稳定很多。但注意,温度太低会导致输出过于保守和重复。我一般设在0.1到0.3之间。
强化Prompt约束:在系统提示里明确要求模型只根据提供的资料回答,不知道就说不知道。我试过在Prompt里加一句“如果资料中没有相关信息,请直接回答‘根据现有资料无法回答’”,胡编的概率明显下降。
增加校验环节:在模型输出之后,加一步校验,检查回答里的关键信息是否能在检索结果里找到。找不到的就标记出来,或者直接返回兜底回答。这个校验可以用简单的字符串匹配,也可以用另一个模型来做。
多次采样投票:对同一个问题生成多个回答,然后选最一致的那个。这个做法成本高,但效果确实好。我一般用在关键场景,比如客服机器人的最终回复。
提示:模型输出不稳定有时候不是模型的问题,而是检索结果本身就不稳定。如果每次检索回来的内容差异很大,模型输出自然也不稳定。这时候要先解决检索的稳定性问题。
5.3 成本与延迟的优化经验
成本和延迟是生产环境必须面对的问题。我分享几个实测有效的优化手段。
缓存:对相同的查询缓存检索结果和模型输出。缓存的粒度可以细一点,比如检索结果按查询缓存,模型输出按Prompt的哈希缓存。我实测下来,缓存能降低30%到50%的调用量,具体取决于查询的重复率。
批处理:如果有多条查询要处理,合并成一批调用。Embedding接口一般支持批量,Chat接口有些也支持。批处理能显著降低网络开销和调用次数。
模型分级:不是所有请求都需要用最强的模型。简单的查询用便宜的小模型,复杂的查询才用大模型。判断复杂度可以用规则,也可以用一个小模型来分类。我一般设一个阈值,检索结果的相似度分数高、问题短的,走小模型;分数低、问题长的,走大模型。
流式输出:流式输出不降低总延迟,但能降低用户感知的延迟。用户看到第一个字的时间从几秒降到几百毫秒,体验提升很明显。如果产品对体验要求高,流式是必须的。
上下文压缩:前面提到的Token预算管理,本质上就是上下文压缩。我还会做一步额外的压缩:把检索结果里重复的、无关的部分去掉,只保留和问题最相关的句子。这个压缩可以用规则做,也可以用模型做。用模型做效果更好,但会增加一次调用。
5.4 我踩过的几个典型坑
坑一:Token计数不准导致超限。我一开始用字符数除以2来估算Token,结果中文场景下严重低估,请求经常超限。后来改用tiktoken精确计数,问题解决。教训是:Token计数不要估算,要用对应模型的分词器精确计算。
坑二:并发调用导致限流。批量向量化的时候我开了50个并发,结果被服务端限流,一半的请求失败。后来把并发降到10,并且加了重试和退避,稳定了。教训是:并发数不是越高越好,要根据服务端的限流策略调整。
坑三:向量存储格式不一致。我一开始用list存向量,后来改成numpy数组,但存储的时候忘了统一,导致加载后类型不一致,相似度计算报错。教训是:向量的存储格式要统一,加载后先做类型检查和转换。
坑四:Prompt里的特殊字符导致解析失败。检索结果里包含了一些特殊字符,拼到Prompt里之后导致模型输出格式混乱。后来我在组装Prompt之前对检索结果做了转义处理,问题解决。教训是:用户输入和检索结果都是不可信内容,拼到Prompt之前要做清洗和转义。
坑五:忽略多轮对话的历史管理。一开始我把所有历史对话都塞进Prompt,结果上下文很快超限,而且模型被历史信息干扰,回答偏离当前问题。后来改成只保留最近几轮,并且对历史做摘要压缩,效果明显改善。教训是:多轮对话的历史不是越多越好,要有选择地保留。
6. 从能跑到好用:几个进阶方向
把基础流程跑通之后,如果想进一步提升效果,有几个方向可以深入。第一个是混合检索,把向量检索和关键词检索结合起来。向量检索擅长语义匹配,关键词检索擅长精确匹配,两者互补。实现方式可以是分别检索然后合并排序,也可以用一个统一的框架。我实测下来,混合检索在专业领域文档上的召回率比纯向量检索高10%到20%。
第二个是重排序。检索回来的Top K结果,用一个更精细的模型重新排序。这个模型可以是交叉编码器,它同时看查询和文档,判断相关性,比向量相似度更准。代价是计算量大,只适合对少量候选做精排。我的做法是先用向量检索召回20条,再用重排序模型选出前5条,效果和成本比较平衡。
第三个是查询理解。用户的查询往往有歧义或者信息不全,做一步查询理解能显著提升检索质量。查询理解包括:意图识别、实体抽取、查询改写、查询扩展。我一般用一个小模型来做这一步,成本可控,效果比规则好很多。
第四个是评估体系。没有评估就没有优化。我建议至少建立一套简单的评估集:准备一批问题和对应的标准答案,每次改动后跑一遍,看准确率、召回率、延迟、成本的变化。评估集不用很大,几十条到几百条就够,关键是要覆盖你的典型场景。我见过很多团队优化全靠感觉,改了半天不知道有没有变好,有了评估集之后,优化方向清晰很多。
这些进阶方向不需要一次性全上,可以按需逐步引入。我的建议是先把基础流程跑稳,有了评估体系之后,再针对性地优化短板。盲目上高级特性,往往投入产出比很低。