基于LLM的智能问答系统竞赛实战:RAG架构与调参避坑指南
简介面向阿里云天池「基于LLM智能问答系统」赛题的完整方案资源适合参赛者与NLP学习者在真实竞赛场景中理解从问题解析到答案生成的实现链路。压缩包内含27个文件以Python脚本和CSV数据集为主体辅以JSON、PDF说明及Shell运行脚本整体体积仅476KB便于快速下载与本地复现各类型文件分工明确Python源码实现核心逻辑CSV提供中间数据JSONL保存提交结果。内容覆盖文本向量化、问题分类与实体识别、SQL自动生成与执行、答案生成等关键环节并配有辅助数据集和依赖清单可直接对照代码梳理赛题思路。目前已有446人学习查看。通过系统阅读源码与数据样例读者可掌握LLM问答系统的模块化设计方法了解如何将自然语言理解与数据库查询结合并基于示例流程扩展自身竞赛方案是一份高性价比的实战参考资料。1. 基于LLM智能问答系统在云厂商竞赛里到底考什么别把它当成聊天机器人当赛题把“基于LLM智能问答系统”这几个字发到你手里时很多人的第一反应是接一个大模型API套一个通用提示词剩下交给模型自由发挥。真跑过一遍就知道这类云厂商竞赛考的不是“你认识哪个模型”而是你能不能在一个限定的知识域里让答案既准确又稳定并且每一次提交之间波动可解释。我见过不少队伍上线了两三天分数没涨甚至因为检索代码在测试集上出现了向量下标越界直接交白卷。这个方向想拿名次靠的是把“问答系统”拆成数据清洗、切片、召回、重排、生成、评测的一整条链路而不是靠某个黑匣子模型。适合谁呢你已经跑通过大模型的基本代码知道generate和temperature分别在做什举但没有系统地做过检索增强问答的工程化这篇文章就是给你踩坑用的。2. 架构选型RAG 还是微调先想清楚这两条路的分工2.1 RAG 和微调的边界什么场景下问答系统需要同时上竞赛里最常见的误区是拿到题目后立刻去准备微调数据觉得“模型不够聪明”。但对一个基于LLM的智能问答系统来说大多数赛题的知识是静态文档或者结构化资料模型本来就会说通用话术缺的是“照着这份资料回答”。这个场景的天然解是 RAG把资料切成块用户提问时先召回相关内容再把内容塞进提示词让模型基于上下文回答。RAG 的好处是知识更新只依赖索引不需要重新训练模型缺点也很明显检索质量直接决定答案上限如果切片切坏了模型再强也拿不到证据。微调适合的是另一类问题模型不遵守输出格式。比如赛题要求每次回答必须输出“结论 引用出处”你用了提示词约束模型还是偶尔会漏掉。又或者知识库里有大量专业术语和固定表达模型总是用自己的口语改写。这时候用几百条高质量的“问题-标准答案”做指令微调效果比反复调提示词更稳定。可微调有一个隐藏代价训练完再想换底座模型整个流程要重跑竞赛时间往往不支持这么折腾。我一般会先跑一个纯 RAG 基线让它拿到一个可耻但稳定的分数再决定要不要额外做微调。顺序反了会非常折磨人。2.2 竞赛考题里常见的三种数据形态和评测指标动手前先建打分器我参与过的这类云厂商竞赛数据一般逃不过三种形态。第一种是“多篇文档 一批问题 标准答案”答案往往能在原文里找到出处第二种是“多轮对话历史 当前问句”要求系统记住上文第三种是“问题 候选答案片段”要求系统从多个片段里拼装答案。三种形态的评测侧重点不一样第一种看检索命中和忠实程度第二种看上下文拼接是否正确第三种看信息整合能力。比数据形态更重要的是先把评测指标想明白。比赛的官方评分往往用一套服务端测评脚本来跑查的是关键词命中、语义相似度、答案中是否包含某几个实体。我在本地会建一个 30 条左右的迷你测试集每条包括“问题—参考答案—来源文档 ID”然后自己写打分函数。这样做的价值不是模拟官方分数而是让每次修改都能看到相对涨跌。很多队伍翻车的点是本地看了一条回答觉得不错提交后分数暴跌其实是因为本地压根没有批量评测全靠人工抽查。2.3 基线架构检索链路和生成链路的分工一个能稳定迭代的 LLM 问答系统我会把它拆成两条独立链路。检索链路负责从文档库里挑出“可能有用”的内容先对文档做清洗和切片把切片向量化用户提问时计算问题向量和切片向量的相似度取 Top-k如果还想更准一点在向量召回之后再加一个重排模型把段落按相关性精排一遍。生成链路则负责“看着资料说话”把最终选中的切片和用户问题拼成提示词交给底座模型生成生成时控制温度、Top-p、最大长度。两条链路之间只有一个接口——拼接好的上下文字符串。把链路拆开最大的好处是出了问题能定位。分数低先看是检索链路拿到的切片本身就不相关还是切片区相关但模型没照着答。前者去调向量模型、切片长度和重排后者去调提示词和解码参数。许多新手把召回和生成写在一个函数里一条输出看着不对马上开始改温度其实真正的原因是检索没有召回正确段落。先有分工才能做对照实验。3. 搭起可复现的 LLM 问答系统模型加载、检索接入和自动评测3.1 用开源底座模型跑通最小问答链路这个部分的目标不是实现一个完整系统而是先在本地拿到“输入问题—输出答案”的最小闭环保证生成链路本身没有毛病。代码需要加载一个底座模型我用的是当前比较主流的模型加载库模型选 7B 量级量化后能塞进单卡。from transformers import AutoModelForCausalLM, AutoTokenizer model_path 你的本地模型目录 # 换成实际路径 tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) model AutoModelForCausalLM.from_pretrained( model_path, torch_dtypeauto, device_mapauto, trust_remote_codeTrue, ) def generate_answer(prompt, max_new_tokens512): messages [{role: user, content: prompt}] text tokenizer.apply_chat_template( messages, tokenizeFalse, add_generation_promptTrue ) inputs tokenizer(text, return_tensorspt).to(model.device) outputs model.generate(**inputs, max_new_tokensmax_new_tokens) response tokenizer.decode( outputs[0][inputs.input_ids.shape[1]:], skip_special_tokensTrue ) return response print(generate_answer(什么是召回率))这段代码里trust_remote_codeTrue是让模型加载允许运行自定义代码一些开源模型的配置文件里会有特殊实现不加这个会直接报错。device_mapauto让模型自己找显存多卡环境下自动切分。max_new_tokens限制的是模型新生成的 token 数量不包含输入部分设太长不仅慢而且容易让模型自己编造后面的话竞赛问答一般 512 到 1024 就够。apply_chat_template会把用户消息包成这个模型习惯的对话格式这一步不能省直接拼接普通文本会让输出质量明显下降。3.2 加入检索增强用向量召回并在提示词中拼装上下文最小问答链路跑通后开始接检索。真实比赛里通常会准备一个文档数据集我的建议是先用一个轻量级文本向量方法跑通流程验证“检索 生成”的交互逻辑再换成更重的向量模型和向量数据库。下面的代码用 TF-IDF 把文档切片转成向量用余弦相似度召回前几个切片。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity # doc_slices 是已经切好的文档切片列表 doc_slices [ 召回率是在所有正样本中被正确识别出的比例。, 精确率是在所有预测为正的样本中真正为正的比例。, # 继续补充 ] vectorizer TfidfVectorizer() doc_vectors vectorizer.fit_transform(doc_slices) def retrieve(query, top_k4): query_vec vectorizer.transform([query]) scores cosine_similarity(query_vec, doc_vectors)[0] top_indices scores.argsort()[-top_k:][::-1] return [doc_slices[i] for i in top_indices] question 什么是召回率 retrieved retrieve(question, top_k4) context \n.join([f片段{i1}: {s} for i, s in enumerate(retrieved)]) prompt f请根据下面提供的资料回答问题。 资料 {context} 问题{question} 如果资料中没有足够信息直接回答“资料中未提及”。 print(generate_answer(prompt))top_k是召回切片数量不是越大越好。切回来太多会让上下文膨胀模型容易被无关片段干扰太少又可能漏掉证据。我这里先取 4后续参数章节会专门讨论怎么调。TF-IDF 的优势是没有额外依赖缺点是不理解语义所以后续如果想提分要把vectorizer换成预训练的向量模型并换成向量数据库的近似检索接口召回逻辑本身不变。3.3 自动化评测脚本同一份测试集上跑出可对比的分数没有评测脚本所有参数调整都像在开盲盒。我习惯准备一个固定的小测试集包括问题和参考答案然后写一个批量跑分脚本。这里的打分用字符串相似度作为快速信号它虽然粗糙但能反映“回答有没有靠近参考答案的用词”。from difflib import SequenceMatcher test_set [ {question: 什么是召回率, reference: 召回率是在所有正样本中被正确识别出的比例。}, {question: 什么是精确率, reference: 精确率是所有预测为正的样本中真正为正的比例。}, ] def similarity_score(pred, ref): return SequenceMatcher(None, pred, ref).ratio() total 0.0 for item in test_set: pred generate_answer(item[question]) score similarity_score(pred, item[reference]) total score print(f问题{item[question]}) print(f预测{pred}) print(f得分{score:.3f}) print(f平均分{total / len(test_set):.3f})这段代码里最关键的是SequenceMatcher(...).ratio()它比较两个字符串的公共子序列占比返回 0 到 1 之间的相似度。它只看字符层面的重合所以如果参考答案和预测答案用词不同但语义相同这里分数会很低说明这个指标只能用来捕捉“大方向对不对”。竞速赛里我应该再用一个 LLM 评分器做语义评测把问题、参考答案、预测答案发给一个大模型让它输出 1 到 5 分。两个指标一起看字符分数负责监控稳定性语义分数负责判断真实性。4. 竞赛提分的四个关键参数温度、Top-p、切片大小与重排条数4.1 温度与Top-p决定回答是保守还是发散解码参数中影响最直接的就是温度temperature和top_p。温度越低模型越倾向选概率最高的词回答变得保守、重复但更适合答题温度越高生成越多样但容易跑题。top_p控制候选集合大小top_p0.9表示只从累计概率达到 90% 的 token 里采样。两者不是独立的实践中通常只调一个另一个保持固定。我一般把temperature0.2、top_p0.8作为问答基线的起点。如果评测分数在多个测试样例上跳来跳去说明采样随机性太大可以把temperature调到0或者把do_sampleFalse强制模型走贪心解码。竞赛环境中稳定比惊艳更重要官方评分可能只跑一次随机波动会让对手看不到你的真实水平。参数常用范围对问答的影响temperature00.5越低越稳定越高越发散top_p0.70.9截断低概率词避免生造max_new_tokens2561024过长会让模型编造后续内容do_sampleFalse / True固定输出时设为 False4.2 检索切片大小200字还是800字差别比你想的大文档切片长度是检索链路里最容易忽略的参数。切片太短比如 200 字优点是有可能更精准定位到答案所在段落缺点是上下文片段多生成模型需要跳着阅读还容易切散关键句。切片太长比如 800 字优点是证据完整缺点是向量表示被平均稀释相似度检索可能召回一个“包含答案但顺带很多噪音”的片段最终在生成时被无关信息带偏。我的经验是先按语义段落切段落太长时再按 300500 字递归切切完做 10% 的重叠。重叠的目的是避免答案边界恰好被一刀切断。竞赛场景里文档格式往往乱七八糟我会先统一把多余换行和 Markdown 标记清理掉再切块。最终效果用后继章节的评测脚本对比同一组问题在 200、400、800 字切片各自的检索召回命中率命中率差异一出来参数就定了。4.3 重排条数召回不是全部进入上下文的只有几条向量召回通常会取 1020 个候选但生成链路无法把这些都塞进一个提示词。重排的作用是在候选里挑出与问题最相关的 35 条。如果你不做重排只是把向量召回的 Top-10 全部拼进上下文模型会“淹没”在无关片段里答非所问的概率直线上升。很多参赛者在这个地方翻车召回没做错但上下文一次给了 2000 多字最大的片段已经盖过了正确答案。我的做法是召回 Top-20重排后取 Top-3 或 Top-4。重排可以用一个轻量级排序模型来跑也可以先用一个快速规则计算问题与每个候选的字符重合率重合率低的直接砍掉。无论选哪种都要把这个“重排后保留的条数”作为变量记下来。我见过某个问题的答案在 Top-5 时能完整答出改成 Top-2 就少了出处说明保留条数太少而改成 Top-8 时多了很多噪音答案开始含糊。最优值通常落在 25 之间。4.4 参数组合的落地方式把玄学变成对照实验前面这几个参数单独看都有道理但组合起来石头一样多。我建议从一开始就把参数组合写进一个配置字典实验时每次只改一个变量其他全部固定。比如我想比较切片大小就固定温度为 0.2、Top-p 为 0.8、重排保留数为 3只把切片从 300 改为 500跑两遍测试集看平均分。这样得到的结论才是可复用的。如果你凭感觉同时调整了温度、切片大小、重排条数和提示词最后分数涨了也不知道是哪个改动在起作用分数跌了更找不回那个“有效版本”。竞赛后期时间金贵一个config_v7.json比一版“最终版代码”值钱得多。养成每跑一次实验就把配置、测试集分数和三条坏案例存下来的习惯后面提分会快很多这不是玄学这是在给系统留后悔药。5. 常见问题与避坑显存溢出、答非所问和评测分数波动的排查5.1 显存溢出模型不大却动不动OOM现象加载一个 7B 量化模型代码在启动阶段就报显存不足或者跑到一半 kernel 卡死。明明模型不大为什么连单卡 24GB 都放不下原因有两个。第一是max_new_tokens设置很长生成时的 KV cache 占显存随着上下文长度增长而翻倍第二是device_mapauto在多卡机器上把层均匀分到两张卡但每张卡还残留着上一次运行的显存碎片新进程加载直接失败。解决先看显存是否被残留进程占用用工具查进程并清理。然后把max_new_tokens从 1024 降到 512并开启 KV cache 量化配置。如果还是 OOM就把加载方式改成 4bit 量化用参数指定load_in_4bitTrue代价是生成速度略微下降但显存占用能减去一半。注意量化后个别模型输出会不稳定需要在评测脚本里对比量化前后同一批问题的分数。5.2 答非所问检索到了但模型自己编答案现象明明把相关资料片段拼到了提示词里模型给出的答案在资料中找不到任何出处看起来很完整其实是编的。原因大多数时候是提示词没有约束模型“看不到就别答”模型天生的补全习惯让它把不存在的词也圆回来。另外上下文里混入多个不相关片段时模型会挑信息最多的段落而不是与问题真正相关的段落。解决第一步在提示词里明确要求“只根据资料回答资料中没有的信息不要补充如果资料不足以回答请直接说‘资料中未提及’。”第二步检查重排后的上下文把最终拼接的内容打印出来自己看一遍如果发现混入无关片段去调重排保留条数或向量模型。我还遇到过切片里把两个不同章节的问题拼到同一个片段里导致模型各自摘了一句变成四不像这种情况只能从切片阶段去修。5.3 评测分数波动同一份代码两次跑分不一样现象本地测试集跑了一遍平均分 0.71没改任何代码又重新跑一次变成 0.65。原因是生成参数里temperature不为 0每次采样路径不同输出自然不同。很多人调参时被这个噪声干扰以为刚才那个参数好实际只是运气好。解决做对比实验时先把temperature设为 0do_sample设为False让生成完全确定性确认当前改动真正带来变化。等确定了参数再恢复采样用于最后提升多样性。同时给评测脚本固定随机种子并跑三次取中位数中位数比平均值抗单次异常。如果官方评测只跑一次你交上去的分数也有随机性所以务必以确定性配置作为最终提交版本。5.4 切块把句子拦腰截断向量检索召回了一堆残片现象召回结果本身看着相关但内容是从中间断开的半句话生成模型拿到这种残片后补全出来的答案极其别扭。原因是切块完全按照固定字符长度切比如每 300 字一刀完全不理会句号和段落自然边界。中文文档中句号位置不定固定长度切几乎一定会切到句中甚至切在两个实体中间。解决在切块前先按段落拆然后对长段落做带重叠的递归切切点优先选择句号、感叹号、问号或换行符。如果找不到这些分隔符才退回到字符边界。切完后可以写一个简单的校验脚本打印超过多少比例的切片是以标点结尾如果比例低于 80%说明切块逻辑要改。这个坑越早排除检索链路后期越省心。5.5 提交格式不对预测答案对不上题目 ID现象本地跑得好好的打包提交后系统返回异常或分数极低。原因通常是最终写文件的循环遍历了 dict而 dict 在 Python 3.7 后保持插入顺序但你在中途可能对测试集做了过滤或去重丢了几条却忘了更新 ID 映射也可能在答案里多写了提示词中的“资料片段”等文字导致官方评测解析不了。解决在预测脚本里维护一个独立的question_id列表预测时按这个列表的下标顺序写结果不要直接遍历字典拼接。写文件时做一次数量校验读回文件检查行数是否等于question_id长度。提交前再跑一个极端测试人为删掉一条测试数据看脚本是否还能正确对齐 ID。这个小检查每次能救回一次无效提交。6. 决赛前我会用的验证习惯多次采样投票与失败样本分析到了比赛后期模型和检索链路基本冻结剩下的工作是把分数稳定住。我会用“多次采样投票”的办法来压低单次生成的随机波动同一个问题用稍高一点的温度跑 5 次把 5 个输出放入一个投票函数选出现次数最多或字符相似度最一致的答案作为最终提交。这个方法不算新但在问答场景下能把一些偶发性漏答救回来。from collections import Counter def sample_vote(question, times5): answers [generate_answer(question, temperature0.7, top_p0.9) for _ in range(times)] # 用最长公共子序列的相似度做聚类这里用首句代表作简化 best_answer Counter(answers).most_common(1)[0][0] return best_answer实际用的时候如果 5 次答案五花八门说明这个问题本身没被检索链路稳定支撑。我会把这些“投票不一致”的问题单独列出来逐一查看召回片段和完整上下文判断是证据缺失还是重排把正确片段排到后面。这个阶段不要大刀阔斧改系统只针对失败样本微调提示词或切片边界。我自己的习惯是修完一轮后重跑全部测试集确认原来涨分的地方没有跌再更新配置。这个习惯不是花哨的包装而是避免临时改参数翻车的最后防线。希望帮到你。本文还有配套的精品资源点击获取