前段时间在实验室做了一套小问答系统,用来处理光纤基础测试相关的技术问题,底座选的是 Qwen3-0.6B。项目本身不算大,但从知识库搭建、模型部署、效果调优到最后的联调,整个过程踩了不少坑,也比较完整。如果你也想在离线环境里搞一套垂直领域问答,或者手头只有低显存设备、甚至纯 CPU 机器,这篇文章应该能帮你省掉不少弯路。
这套系统解决的核心问题很具体:新来的测试工程师经常拿着 OTDR 曲线来问“这个台阶是什么原因”、“损耗超标怎么排查”,或者问“光功率计和光源对测损耗的正确步骤”。这些问题翻手册能找到,但手册几十页,检索成本高。我们想要的是把光纤测试中的操作规程、指标含义、故障判断经验整理进知识库,再通过对话方式直接给出答案。出于数据不出内网和成本控制的考虑,没有使用在线大模型 API,而是选了 Qwen3-0.6B 这个小模型本地部署,再用检索增强生成(RAG)补上专业知识的不足。
整体做下来,效果还算符合预期。系统上线后,对最常见的测试操作类问题,回答准确率能到 85% 左右,单次回答延迟在 CPU 上也能控制在 5 秒内。下面我把整个设计与实现过程完整拆开,讲讲每一步为什么这么选、具体怎么做,以及后面调试中踩过哪些坑。
1. 需求拆解与方案定位
1.1 核心场景:光通信测试工程师的“已知问题库”
作问答系统的第一步,不是急着部署模型,而是想清楚用户到底会问什么。光纤基础测试这个场景看着范围小,深入一拆会发现知识密度非常高。拿最常见的几类问题来说:
- 损耗计算类:比如“光源输出 -3.5dBm,光功率计测到 -7.8dBm,链路损耗是多少?”这类题目本质是公式套用,模型只要理解“损耗 = 发射功率 - 接收功率”,再按 dB 做差值。
- OTDR 曲线解读类:比如“OTDR 曲线上有一段斜率明显变陡,是什么原因?”这涉及到光纤弯曲、熔接点损耗、活动连接器污染等判断逻辑,答案不是简单公式,而是一组经验规则。
- 操作规程类:比如“使用光功率计测试前为什么必须清洁端面?”、“光源和光功率计应该先连接哪一端?”这类问题在培训中反复出现,属于流程性知识。
- 指标判断类:比如“单模熔接损耗多少算合格?”、“回波损耗和插入损耗有什么区别?”
这些问题的共性是:答案基本靠测试规范、仪表手册和老师傅经验积累,没有太多需要“创作”的内容。也就是说,知识库里的内容是封闭的、相对固定的。这正好是小模型加检索增强最适合的场景——模型不需要知道所有事,只需要学会“如何从海量片段里找到相关知识,再组织成一句有条理的话”。
1.2 为什么选 Qwen3-0.6B 而不是更大的模型或在线 API
先看需求约束。项目要求三条:第一,数据不能出内网,排除在线 API;第二,部署环境是一台 16G 内存的 CPU 服务器,没有可用的 GPU;第三,日常问答要快,不能接受大模型动辄十几秒的延迟。
在这三条前提下,模型选择就是一个倒推题。7B 级别的模型虽然能力更强,但在纯 CPU 环境里加载和推理都很吃力,首次推理时间可能直接飙到几十秒,而且内存占用会压过 10G,留给知识库和向量检索的余量就不够了。3B 左右的模型稍好一些,但量化后仍然有大概 2G 多的显存或内存需求,整机负载偏高。最终选择 Qwen3-0.6B 的原因有三个:
- 参数规模足够小。模型权重以 FP32 格式加载也只有 2.4G 左右,内存压力很小;留出大量内存给向量库和系统运行环境。
- 中文基础能力不弱。Qwen 系列的中文指令遵循能力在小模型里属于第一梯队,尤其在短文本总结、格式输出上比很多同尺寸模型稳。
- 配合外挂知识库可以弥补参数不足。既然问题答案都来自知识库,模型的主要任务是“阅读片段并复述”,这正好是小型模型的强项。
下图是当时做的方案对比,可以直观看到取舍逻辑。
| 方案 | 内存/显存压力 | 推理速度 | 离线要求 | 垂直知识准确率(初期评估) | 结论 |
|---|---|---|---|---|---|
| 在线大模型 API | 无本地压力 | 快但有网络不确定 | 不满足 | 高但不可控 | 否 |
| 本地 7B 量化模型 | 较高 | CPU 上较慢 | 满足 | 高 | 性价比低 |
| 本地 3B 量化模型 | 中等 | CPU 上可接受 | 满足 | 中高 | 可备选 |
| 本地 Qwen3-0.6B | 低 | CPU 上较快 | 满足 | 中 | 选定 |
后来实际测试发现,0.6B 模型在裸对话时对光纤专业问题确实会“胡说八道”,但这不是模型弱,而是用法不对。把检索增强和提示词约束做好之后,效果提升非常明显,这也说明“小参数加好工程”这条路完全走得通。
1.3 系统总体架构:轻量 RAG + 可选微调
系统整体分三个模块:知识库处理模块、检索模块、生成模块。知识库处理模块负责把技术手册、培训讲义、测试操作规程等原始文档切成文本片段,再用向量模型转成向量;检索模块接收用户问题,先做语义相似度检索,找出最相关的片段;生成模块把片段和问题拼进提示词,交给 Qwen3-0.6B 生成最终答案。
这个架构可以随时拆掉任何一环单独调试。比如检索不准就调切片大小和相似度阈值;生成答案不听话就改提示词模板;如果后续发现模型始终理解不了特定意图,再考虑对模型做 LoRA 微调。整个过程可以由浅入深,不用一上来就跑完整流程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 知识库建设与数据准备
2.1 数据来源与清洗:先把“脏文档”变成干净语料
光纤测试的知识库数据主要来自几类文档:仪表使用手册的 PDF、企业内部培训 PPT、测试规范文档、以及老师傅口述整理的操作纪要。这些材料格式参差不齐,最常见的问题是 PDF 里表格被拆行、PPT 文字缩进混乱、扫描件无法直接提取。
第一步是统一转文本。PDF 我建议不要直接整本丢给大模型切块,因为表格结构会丢失,前后文关系也会断裂。更可靠的做法是用文档解析工具把 PDF 中的文本块、表格块分别抽取出来,再把表格转换成“属性-值”形态的文本。比如 OTDR 测试记录表,转换后应变成类似“事件类型:反射峰;距离:12.35km;损耗:0.23dB”这样的句子,而不是把整张表糊在一起。
清洗阶段的几个关键操作:
- 去掉页眉页脚、目录、重复的空行和特殊符号。
- 把单位统一成常用写法:dBm、dB、km、nm。
- 对专业术语做同义词归一,例如“OTDR”和“光时域反射仪”可以保留原词,但在检索时通过向量模型自动处理语义相近问题。
- 把带编号的步骤重新整理成清晰的动作序列,例如“第一步清洁端面、第二步连接光源、第三步设置参考值”。
清洗后的数据质量直接影响后续检索效果,这一步不能省。项目初期我们跳过清洗,直接把 PDF 全文切块丢进向量库,结果检索到的片段经常是页眉文字或者断行的表格,问答效果自然很差。后来花了整整两天重做清洗,检索命中率才算回到正常水平。
2.2 文本切块策略:不是越长越好,也不是越短越好
把清洗后的文档切成小片段,是 RAG 系统最关键的技术细节之一。切块大小如果太大,一个片段包含多个知识点,检索时会带来噪声;如果太小,片段里的语义不完整,模型无法组织答案。我们经过多组实验,最终采用的策略是:
- 基础切块长度:512 个字符
- 重叠长度:50 个字符
- 切块单位:优先按段落边界,其次按句号边界
重叠部分很重要。如果直接硬切,恰好把“损耗计算公式”和“计算示例”切成两半,检索时就可能只找到一半,模型看着就答不完整。重叠 50 个字符能确保边界处的关键信息在相邻两个片段里都会出现,至少有一边能覆盖完整内容。
还要考虑到光纤测试文档中常见的一组一组短句。比如 OTDR 盲区的定义:“盲区是 OTDR 无法识别事件的最短距离,分为事件盲区和衰减盲区。”单独成段,很好切。但如果是表格里的多个参数说明,就要手动把每一行合并成一个完整的“字段名:值 + 解释”的单元,避免切块把一个参数定义切成两半。
做完切块后,要对每个片段加一个“来源标识”,格式可以是“文档名|章节|片段编号”。这个标识在回答阶段非常有价值。用户问题如果涉及某个仪表型号,检索到对应片段的来源标识会提示模型把型号上下文带上,减少答错。
2.3 向量化与索引构建:中文场景的细节
文本片段不是直接存文本,而是要转成向量做语义检索。我们采用的向量模型是一个开源中文向量模型 bge-small-zh-v1.5,向量维度 512,单条检索速度在 CPU 上很快。之所以不选更大的向量模型,是从工程角度考虑:0.6B 的底座模型本身不大,整个链路应该保持轻量,不需要为了极小精度提升拖慢系统。
向量化的流程大致如下:先给每个片段生成一个“检索文本”,检索文本不一定要和片段内容完全一致,可以在开头加上关键词和类别。例如“OTDR 盲区定义”这个片段,检索文本可以写成“OTDR 盲区 定义 事件盲区 衰减盲区 + 原文片段内容”。这样用户问题里出现“盲区是什么意思”时,检索匹配的分数会更高。
向量库我们使用了轻量的持久化向量数据库,把索引文件直接存放在本地目录。这样做的好处是服务重启后不需要重新向量化整个知识库,节省大量时间。构建索引的简化代码可以参考:
python复制from sentence_transformers import SentenceTransformer
import chromadb
embedder = SentenceTransformer("bge-small-zh-v1.5")
client = chromadb.PersistentClient(path="./fiber_kb")
collection = client.get_or_create_collection(
name="fiber_basic_test",
metadata={"hnsw:space": "cosine"}
)
fragments = load_cleaned_fragments() # 返回 id、文本、来源元数据
for item in fragments:
embeddings = embedder.encode([item["search_text"]]).tolist()
collection.add(
ids=[item["id"]],
embeddings=embeddings,
documents=[item["content"]],
metadatas=[{"source": item["source"]}]
)
这里有个容易踩的坑:向量模型直接对整句编码时,如果句子超过 512 个 token,有些模型会自动截断。而我们切块的字数是 512 个字,对某些中文模型来说并不等于 512 个 token。要提前确认向量模型的 max_seq_length,必要时把切块长度再降到 400 字左右。
2.4 人工构造问答对:既是评测集,也是微调语料
知识库切片只解决“模型去哪里找知识”,还不够解决“模型怎么回答问题”。想要知道系统效果怎么样,必须有一批标准问答对。我们从培训记录、论坛常见问题、工程师日常提问里整理出 500 条问答对,覆盖操作流程、指标计算、OTDR 案例、设备维护四类。其中一部分用来做自动评测,一部分后来转成微调语料。
构造问答对时要注意:一个问题只能有一个标准答案来源,减少评测时歧义。比如“ODF 面板怎么清洁”和“光纤连接器端面怎么清洁”本质相似,但在设计时要把场景分开,不能混在一个答案里。每条问答对都要标注“对应原文片段 ID”,这样才能在 RAG 失败时快速定位是“检索没找到”还是“生成出了问题”。
这一阶段产出的问答对,基本决定了系统的知识边界。后续评估发现的错误,往深挖大多都能回到这里:要么是问答对没覆盖某个问法,要么是标准答案本身不完整,导致模型虽然检索到了片段但不知道怎么答。
3. 系统实现与模型部署
3.1 部署方式选择:直接用 Transformers 还是套框架
Qwen3-0.6B 的部署方式有两条路线。第一是用现成的推理框架比如 Ollama,好处是命令一行搞定,自带 API 接口,适合快速原型。第二是用 Transformers 库直接加载模型,自定义程度更高,方便接入 RAG 管道和后续微调,适合需要深度定制的正式项目。
考虑到我们的问答管道要和知识库检索做深度拼接,还要注入复杂的提示词模板,我选择了 Transformers 路线。当然,这不是说 Ollama 不行,如果你只是想要一个“跑起来看看效果”的 demo,Ollama 会更快。但在生产环境里,用 Transformers 可以直接拿到 tokenizer 和模型对象,自由度大得多,排错也更方便。
模型加载部分一开始使用 FP32 格式,在 CPU 上也能跑,但速度偏慢。后面改成 4-bit 量化加载,内存占用进一步下降,速度提升明显。初始化代码大致长这样:
python复制from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_name = "Qwen/Qwen3-0.6B"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
model_name,
torch_dtype=torch.float32,
device_map="cpu",
quantization_config=None,
trust_remote_code=True
)
如果你的环境里有 GPU,可以把 torch_dtype 换成 torch.float16,并设置 device_map="auto"。但我们在实际部署时是用 CPU 环境,所以更推荐量化模型,也就是把模型权重转成 int4 格式。这样在 16G 内存的机器上跑起来很宽裕。
3.2 RAG 问答管道的完整实现
整个问答管道写成一个 Python 类,负责三件事:接收问题、检索片段、拼装并调用模型生成。我把核心代码简化成下面的伪代码结构,实际操作时直接填充细节即可。
python复制class FiberQA:
def __init__(self):
self.collection = load_vector_collection()
self.embedder = load_embedder()
self.tokenizer = load_tokenizer()
self.model = load_model()
def retrieve(self, question, top_k=3, threshold=0.5):
q_emb = self.embedder.encode([question]).tolist()
result = self.collection.query(
query_embeddings=q_emb,
n_results=top_k,
)
# 过滤低于阈值的片段
filtered = [
(doc, score) for doc, score in
zip(result["documents"][0], result["distances"][0])
if score >= threshold
]
return filtered
def generate(self, question, context):
prompt = build_prompt(question, context)
inputs = self.tokenizer(prompt, return_tensors="pt")
outputs = self.model.generate(
**inputs,
max_new_tokens=512,
temperature=0.7,
top_p=0.9,
repetition_penalty=1.3,
do_sample=True
)
answer = self.tokenizer.decode(outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True)
return answer
def answer(self, question):
contexts = self.retrieve(question)
if not contexts:
return "当前知识库中没有检索到相关内容,请尝试换一种问法。"
context_text = "\n".join([doc for doc, _ in contexts])
return self.generate(question, context_text)
两个细节值得说。一是相似度阈值,我们用的是余弦距离,向量数据库返回的 distance 越小表示越相似,所以代码里判断 score >= threshold 其实要看你选择的向量库具体返回的是什么,如果是 similarity 则取越大越好,写代码前要确认清楚。二是 max_new_tokens 不能设得太大,光纤测试问题的标准答案通常不超过 300 字,512 的上限已经够用。设太大反而容易让模型在答完后又补一段不相关的话。
3.3 提示词模板:小模型最需要“结构化命令”
Qwen3-0.6B 不是那种可以自由发挥的大模型,它需要非常清晰的指令格式。我们在调试中发现,提示词写得不明确时,模型很容易把检索片段原封不动抄进答案,甚至出现“1. 2. 3.”和“首先、其次”混用的混乱句式。
最终设计的提示词模板如下:
code复制你是一名光纤线路测试领域的技术支持工程师。
请只根据【参考片段】中的内容回答用户问题。
如果参考片段中没有相关信息,请直接回复:“当前知识库中没有查到相关答案。”
回答时不要编造数值,不要扩充与问题无关的内容,控制在200字以内。
【参考片段】
{context}
【用户问题】
{question}
【回答】
这个模板有四个关键约束:一是限定角色,让模型的语气稳定;二是限定“只根据片段”,降低幻觉概率;三是给“不知道”一个标准回复,避免模型硬答;四是限制字数,防止答案冗长。实测下来,加了第四点后答案质量提升很明显,特别是回答操作步骤类问题时,模型不再长篇大论。
4. 微调与效果优化
4.1 只靠 RAG 够不够:先看瓶颈在哪里
刚开始我们对 500 条评测问题做测试,发现直接裸模型回答的准确率只有 42% 左右。这个数字不奇怪,0.6B 模型本身在专业领域缺少知识储备。加入 RAG 之后,准确率提升到 68%,说明检索片段确实把大方向拉回来了。
但剩下的 32% 错误里,有很大一部分不是没有检索到正确片段,而是模型“看懂了片段却答非所问”。典型表现是:用户问“OTDR 的盲区是什么意思”,模型检索到了盲区定义,却答成“建议使用更短的脉冲”。这就是指令遵循能力不够的表现。这时候就需要考虑对底座模型做一次轻量微调,让模型更适应“阅读片段然后回答”的任务。
4.2 LoRA 微调:用小数据量纠正模型行为
微调目标不是给它输入更多光纤专业知识,而是让模型学会我们的回答格式。我们使用的数据来自人工整理的 500 条问答对,按照“指令 + 参考片段 + 标准答案”的格式组织成训练样本。如果你也有类似需求,这个思路可以复制:训练样本里的“参考片段”就是 RAG 检索出来的真实片段,“标准答案”就是我们需要模型输出的标准表达。这样模型在训练时会学到:给定片段之后,要用片段信息写成符合我们风格的答案。
LoRA 训练参数我给出了一份参考表,在单张 8G 显存的显卡上可以跑,纯 CPU 也可以跑但时间会较长。
| 参数 | 取值 | 说明 |
|---|---|---|
| LoRA rank | 8 | 低秩矩阵维度,0.6B 模型不需要太高 |
| LoRA alpha | 16 | 缩放系数,一般设为 rank 的 2 倍 |
| dropout | 0.1 | 防止过拟合 |
| 学习率 | 2e-4 | 小模型微调常用范围 |
| batch size | 4 | 受显存限制 |
| 训练轮数 | 3 | 数据量不大,防止过拟合 |
| 上下文长度 | 1024 | 覆盖提示词、检索片段和答案 |
训练语料里每条样本的输入格式和推理时的提示词保持一致。我们使用了常用的指令微调框架,在原始模型上挂 LoRA 适配器,训练结束后再合并权重保存。微调后的模型在同样评测集上准确率到了 82%,并且回答格式稳定了很多。
4.3 评测结果与方案组合分析
为了完整评估不同方案的贡献,我们做了一个对比实验,专门用 50 条从未出现在训练数据里的新问题打分。打分标准分为三档:完全正确、部分正确、错误。下表是最终结果:
| 方案 | 完全正确率 | 部分正确率 | 错误率 |
|---|---|---|---|
| Qwen3-0.6B 裸对话 | 24% | 28% | 48% |
| RAG + 人工提示词 | 46% | 32% | 22% |
| RAG + LoRA 微调 | 62% | 30% | 8% |
| RAG + LoRA + 答案后处理 | 68% | 28% | 4% |
注意这里的“完全正确”标准比较严格,要求答案中的数值、步骤顺序、专业术语全对。最终上线版本选择的是“RAG + LoRA + 答案后处理”这一套,完全正确率 68%,如果放宽到“关键信息正确”则有 96%。对一个小模型来说,这个表现已经很能打。
做评测时还有一点提醒:不要只看准确率,要关注错误原因。我们把每个错误答案都回放了一遍,发现错误集中在两类:一是计算类问题步骤对但最终数字错,二是 OTDR 曲线诊断问题缺少具体场景。这也为后续扩展知识库指明了方向。
5. 常见问题与排错实录
5.1 高频问题速查表
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| 回答不断重复同一句话 | 采样参数问题,尤其是 repetition_penalty 过低 | 调高 repetition_penalty 到 1.3 |
| 答非所问,答得和问题无关 | 检索 TopK 过大,混入不相关片段 | 将 TopK 降到 3,并提高相似度阈值 |
| 生成了知识库根本没有的数值 | 提示词约束不足,模型自由发挥 | 提示词中加“只根据片段回答” |
| 首字响应很慢 | 模型是 FP32 在 CPU 运行 | 改成 4-bit 量化或使用 GPU 推理 |
| 回答格式混乱,时而列表时而段落 | 提示词中没规定输出格式 | 在提示词中指定“请用简洁段落或编号列表” |
| 向量检索返回结果不稳定 | 切块大小不一致导致语义断裂 | 统一切块长度,保留段落边界 |
| 训练后模型遗忘基础知识 | LoRA 学习率过大或训练轮次过多 | 调低学习率,减少训练轮数,加验证集 |
这张表里的很多问题是同时出现的。比如回答重复和回答过长,经常是因为 max_new_tokens 设得太大又没设 repetition_penalty。我在调试时会先用一条固定问题做回归测试,每次只改一个参数,避免多个改动叠加后说不清是哪个因素生效。
5.2 三个印象最深的排查经历
第一个坑是向量库距离定义搞混。我们用的向量库默认返回的是余弦距离,距离越小表示越相似。有一次我把 thresholds 逻辑写反了,导致系统只在很差匹配时返回结果。排查半天,最后在打印检索 score 时才意识到是距离定义问题。这个提醒很有用:接任何第三方向量库之前,先打印一条结果确认 score 大小关系。
第二个坑是文本切块长度和向量模型最大长度的冲突。我们最初切了 512 个字,但向量模型会自动截断到 512 token,很多中文字被截没。检索结果看起来还可以,实际上回答问题时会发现片段内容缺后半段。后来把切块长度降到 400 字左右,修复了大部分“内容不完整”的问题。
第三个坑更隐蔽:当用户问“光纤清洁步骤是什么”时,系统检索到一步“光纤清洁操作流程”,本来是对的,但提示词模板里把参考片段放在问题前面。模型有时会把“参考片段”误当成标题,回答开头变成“参考片段如下:”。后来在提示词里给片段加了明确的起始和结束标记,例如“以下是知识库片段,不要直接引用文件名”,问题才消失。
6. 一点个人体会
从项目整体来看,Qwen3-0.6B 确实不是能力最强的模型,但把它放在一个垂直知识库后面,反而比很多盲目套大模型 API 的方案更实用。最核心的经验是:小模型问答系统的效果,主要取决于知识库质量和检索精度,而不是模型本身的“聪明程度”。如果你手里资源有限,与其花大量时间微调,不如先把知识库清洗、切块、向量化这部分做扎实。
另一个想分享的小技巧是:对 0.6B 这种小模型,不要贪心给它太长的上下文。RAG 检索到的片段如果能控制在 3 段以内,生成效果远好于把 10 段都塞进去。信息越多,小模型越容易迷失重点。实际运行时,我们在检索模块就限制最多返回 3 个片段,并在提示词中明确“只使用与问题直接相关的部分”,这比让模型自己区分主次要可靠得多。
这套系统目前还在持续完善中,后续计划补充更多的 OTDR 案例数据,同时针对计算类问题加入简单的规则校验层,在模型输出基础上再做一次数字和单位检查。如果你也在做类似的小模型问答项目,欢迎沿着这套思路去复现和优化。上面所有代码和参数,基本可以直接拷贝到自己的项目里作为起点。
