LLM微调数据集构建:垂直领域网页数据的自动化清洗与标注
垂直领域微调,卡在数据上
通用大模型在开放问答上已经很强了,但一旦丢进医疗、法律、金融这类垂直领域,幻觉率立刻飙升。原因不复杂——预训练语料里垂直领域的专业内容占比太低。要让模型在特定领域靠谱,微调是必经之路,而微调的效果上限由数据集质量决定。
问题就在这:公开的垂直领域微调数据集极少,质量参差不齐,而且不一定匹配你的业务场景。大多数团队最终得自己构建数据集。而自建数据集最耗时的环节不是采集,是清洗和标注——原始网页里混着导航栏、广告、模板文字、重复内容、乱码,把这些噪音洗掉,再把剩下的内容组织成模型能学的格式,工作量往往占整个数据集构建周期的70%以上。
这篇文章拆解从原始网页到微调数据集的完整流水线,重点讲清洗和标注的自动化方案。
整体流水线
原始网页
│
├── 第一阶段:采集 ──→ 代理IP分散请求,获取目标领域网页
│
├── 第二阶段:清洗 ──→ 去噪、去重、编码修复、语言识别
│
├── 第三阶段:标注 ──→ 指令构造、质量评分、人工抽检
│
└── 第四阶段:入库 ──→ 格式标准化、训练集/验证集划分
第一阶段:垂直领域数据采集
采集源筛选
垂直领域的高质量内容通常集中在以下几类来源:
| 领域 | 高质量来源 | 内容特征 | 采集难度 |
|---|---|---|---|
| 医疗 | 医学期刊、临床指南、药品说明书 | 专业术语密集,结构化程度高 | 中 |
| 法律 | 判例文书、法规库、法律评论 | 长文本,逻辑严密 | 中 |
| 金融 | 财报、研报、监管公告 | 数据密集,表格多 | 中-高 |
| 技术 | 技术文档、开发者社区、论文 | 代码与文本混合 | 低-中 |
| 教育 | 课程大纲、教材、学术文章 | 知识体系完整 | 低-中 |
代理IP采集方案
垂直领域网站通常部署在特定国家境内,且对高频访问有反爬限制。采集时需要用目标国家本地IP分散请求。
国内垂直领域采集,使用网帆代理短效动态IP,API请求格式:
http://api.fanproxy.com/?key=abc123&area=110100&isp=电信&count=2&pattern=json&rreg=true&rcity=true&risp=true&rexp=true
参数说明:area为地区代码(如110100为北京),isp指定运营商,count为获取IP数量,pattern为返回格式,rreg/rcity/risp/rexp控制返回字段。
海外垂直领域采集,使用网帆代理动态住宅IP,API请求格式:
http://api.fanproxy.com/?key=abc123&cnt=AS&cty=JP&count=2&pattern=json&rcnt=true&rcty=true&rreg=true&risp=true&rexp=true
参数说明:cnt为洲代码(AS亚洲、EU欧洲、NA北美),cty为国家代码(如JP日本),rcnt/rcty控制返回洲和国家信息。
网帆代理动态住宅IP汇聚全球200+国家及地区的9000万+真实家庭住宅节点,可用率99.9%。国内短效动态IP池规模3000万+,覆盖全国300+城市,IP纯净度99.83%,平均延迟0.03秒。
注意: 网帆代理海外动态住宅IP产品仅支持在境外网络环境下使用。
采集代码示例
import requests
import time
import json
class VerticalDataCollector:
"""垂直领域数据采集器"""
def __init__(self, api_key):
self.api_key = api_key
def get_domestic_ip(self, area="110100", isp="电信", count=2):
"""获取国内代理IP"""
url = f"http://api.fanproxy.com/?key={self.api_key}"
params = {
"area": area,
"isp": isp,
"count": count,
"pattern": "json",
"rreg": "true",
"rcity": "true",
"risp": "true",
"rexp": "true"
}
resp = requests.get(url, params=params, timeout=10)
return resp.json()
def get_overseas_ip(self, cnt="AS", cty="JP", count=2):
"""获取海外代理IP"""
url = f"http://api.fanproxy.com/?key={self.api_key}"
params = {
"cnt": cnt,
"cty": cty,
"count": count,
"pattern": "json",
"rcnt": "true",
"rcty": "true",
"rreg": "true",
"risp": "true",
"rexp": "true"
}
resp = requests.get(url, params=params, timeout=10)
return resp.json()
def collect_page(self, target_url, proxy_ip, proxy_port):
"""通过代理IP采集页面"""
proxy_url = f"http://{proxy_ip}:{proxy_port}"
proxies = {"http": proxy_url, "https": proxy_url}
session = requests.Session()
session.proxies = proxies
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36"
})
try:
response = session.get(target_url, timeout=15)
if response.status_code == 200:
return {
"url": target_url,
"html": response.text,
"encoding": response.encoding,
"status": response.status_code,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
print(f"采集失败: {target_url} - {e}")
return None
# 使用示例:采集国内医疗领域数据
collector = VerticalDataCollector("abc123")
# 获取北京电信代理IP
ip_data = collector.get_domestic_ip(area="110100", isp="电信", count=2)
for ip_info in ip_data.get("data", []):
result = collector.collect_page(
"https://medical-journal.example.com/article/123",
ip_info["ip"],
ip_info["port"]
)
if result:
print(f"采集成功: {result['url']}, 内容长度: {len(result['html'])}")
第二阶段:自动化清洗
原始网页直接喂给模型等于自杀。一个典型网页中,正文内容可能只占HTML体积的20%-30%,其余全是导航栏、侧边栏、广告、脚本、模板文字。清洗的目标是把这些噪音剥离,只留正文。
清洗流水线
原始HTML
│
├── Step 1: 编码修复 ──→ 统一UTF-8编码
│
├── Step 2: 标签剥离 ──→ 去除script/style/nav/footer
│
├── Step 3: 正文提取 ──→ 识别主体内容区域
│
├── Step 4: 噪音过滤 ──→ 去除广告、模板、cookie提示
│
├── Step 5: 语言识别 ──→ 确认实际语种
│
├── Step 6: 去重 ──→ URL去重 + 内容指纹去重
│
└── Step 7: 质量评分 ──→ 文本质量打分,过滤低质内容
清洗代码实现
import re
import hashlib
from bs4 import BeautifulSoup
from urllib.parse import urlparse
class DataCleaner:
"""网页数据自动化清洗"""
def __init__(self):
self.url_seen = set()
self.content_hashes = set()
def fix_encoding(self, html, declared_encoding=None):
"""Step 1: 编码修复"""
if declared_encoding:
try:
html = html.decode(declared_encoding).encode('utf-8')
except Exception:
pass
try:
html = html.decode('utf-8')
except Exception:
html = html.decode('utf-8', errors='ignore')
return html
def strip_noise_tags(self, html):
"""Step 2: 剥离噪音标签"""
soup = BeautifulSoup(html, 'html.parser')
# 移除script、style、nav、footer、aside等噪音标签
noise_tags = ['script', 'style', 'nav', 'footer', 'aside',
'header', 'noscript', 'iframe', 'form']
for tag in noise_tags:
for element in soup.find_all(tag):
element.decompose()
# 移除常见广告class
ad_patterns = ['ad', 'advertisement', 'banner', 'popup',
'cookie', 'subscribe', 'newsletter']
for pattern in ad_patterns:
for element in soup.find_all(class_=re.compile(pattern, re.I)):
element.decompose()
return str(soup)
def extract_main_content(self, html):
"""Step 3: 正文提取"""
soup = BeautifulSoup(html, 'html.parser')
# 优先提取article标签
article = soup.find('article')
if article:
return article.get_text(separator='\n', strip=True)
# 其次提取main标签
main = soup.find('main')
if main:
return main.get_text(separator='\n', strip=True)
# 回退策略:找文本密度最高的div
divs = soup.find_all('div')
if divs:
best_div = max(divs, key=lambda d: len(d.get_text(strip=True)))
return best_div.get_text(separator='\n', strip=True)
return soup.get_text(separator='\n', strip=True)
def filter_noise_text(self, text):
"""Step 4: 噪音文本过滤"""
lines = text.split('\n')
filtered = []
for line in lines:
line = line.strip()
if not line:
continue
# 过滤过短的行(通常是导航或模板文字)
if len(line) < 20:
continue
# 过滤cookie/隐私提示类文本
noise_keywords = ['cookie', '隐私政策', '同意', '订阅',
'copyright', '©', 'all rights reserved']
if any(kw in line.lower() for kw in noise_keywords):
continue
filtered.append(line)
return '\n'.join(filtered)
def deduplicate(self, url, content):
"""Step 6: 去重"""
# URL去重
normalized_url = urlparse(url).path
if normalized_url in self.url_seen:
return False
self.url_seen.add(normalized_url)
# 内容指纹去重
content_hash = hashlib.md5(content.encode()).hexdigest()
if content_hash in self.content_hashes:
return False
self.content_hashes.add(content_hash)
return True
def score_quality(self, text):
"""Step 7: 质量评分(0-100)"""
score = 0
# 文本长度(权重30分)
if len(text) > 2000:
score += 30
elif len(text) > 500:
score += 20
elif len(text) > 200:
score += 10
# 信息密度:字母和数字占比(权重20分)
if len(text) > 0:
alpha_num_ratio = sum(c.isalnum() for c in text) / len(text)
if alpha_num_ratio > 0.6:
score += 20
elif alpha_num_ratio > 0.4:
score += 10
# 段落结构:换行符密度(权重20分)
paragraph_density = text.count('\n') / max(len(text), 1)
if 0.005 < paragraph_density < 0.05:
score += 20
# 专业术语密度(权重15分)
# 根据垂直领域自定义术语表
domain_terms = ['诊断', '治疗', '症状', '临床', '患者', # 医疗示例
'剂量', '禁忌', '不良反应', '适应症']
term_count = sum(text.count(term) for term in domain_terms)
if term_count > 5:
score += 15
elif term_count > 2:
score += 8
# 编码完整性(权重15分)
try:
text.encode('utf-8')
score += 15
except Exception:
pass
return score
def clean(self, html, url):
"""完整清洗流程"""
# Step 1: 编码修复
html = self.fix_encoding(html)
# Step 2: 标签剥离
html = self.strip_noise_tags(html)
# Step 3: 正文提取
text = self.extract_main_content(html)
# Step 4: 噪音过滤
text = self.filter_noise_text(text)
# Step 6: 去重
is_unique = self.deduplicate(url, text)
if not is_unique:
return None
# Step 7: 质量评分
quality_score = self.score_quality(text)
return {
"url": url,
"text": text,
"quality_score": quality_score,
"length": len(text)
}
# 使用示例
cleaner = DataCleaner()
result = cleaner.clean(raw_html, "https://example.com/article/1")
if result and result["quality_score"] >= 60:
print(f"清洗完成,质量分: {result['quality_score']}")
清洗质量基准
| 质量分数 | 内容特征 | 处理建议 |
|---|---|---|
| 80-100 | 正文完整,结构清晰,术语密集 | 直接进入标注阶段 |
| 60-79 | 正文基本完整,有少量噪音 | 人工抽检后使用 |
| 40-59 | 正文不完整,噪音较多 | 需人工审核 |
| <40 | 噪音为主,正文极少 | 丢弃 |
第三阶段:自动化标注
微调数据集的标注,本质上是把清洗后的原始文本转换成模型可学习的指令-响应对(Instruction-Response pairs)。这一步是整个流水线中最难自动化的环节。
标注策略
根据微调任务类型,标注策略不同:
| 微调任务 | 标注格式 | 标注方法 | 自动化程度 |
|---|---|---|---|
| 领域问答 | Q-A对 | LLM自动生成+人工校验 | 中 |
| 文本分类 | 文本-标签 | 规则匹配+LLM辅助 | 高 |
| 信息抽取 | 文本-实体对 | NER模型+人工校验 | 中-高 |
| 摘要生成 | 原文-摘要 | LLM生成+人工校验 | 中 |
| 翻译 | 原文-译文 | LLM生成+人工校验 | 中 |
指令数据自动构造
以医疗领域问答微调为例,用LLM从清洗后的专业文本中自动构造问答对:
class InstructionGenerator:
"""指令数据自动构造器"""
def __init__(self, llm_api_key):
self.llm_api_key = llm_api_key
self.llm_url = "https://api.llm-provider.com/v1/chat/completions"
def generate_qa_pairs(self, source_text, domain="医疗", num_pairs=3):
"""从专业文本中生成问答对"""
prompt = f"""你是一个{domain}领域的数据标注专家。
请从以下专业文本中提取{num_pairs}个高质量的问答对,用于大模型微调。
要求:
1. 问题应该具体、明确,模拟真实用户可能提出的疑问
2. 答案必须严格基于原文内容,不得编造
3. 答案应完整、准确,包含原文中的关键信息
4. 输出JSON格式
专业文本:
{source_text}
输出格式:
[{{"instruction": "问题", "output": "答案"}}, ...]
"""
headers = {
"Authorization": f"Bearer {self.llm_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": "your-model-name",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3
}
try:
resp = requests.post(self.llm_url, json=payload,
headers=headers, timeout=30)
result = resp.json()
content = result["choices"][0]["message"]["content"]
# 解析LLM输出的JSON
import json
qa_pairs = json.loads(content)
return qa_pairs
except Exception as e:
print(f"标注失败: {e}")
return []
def validate_qa_pair(self, qa_pair, source_text):
"""校验问答对质量"""
instruction = qa_pair.get("instruction", "")
output = qa_pair.get("output", "")
issues = []
# 长度检查
if len(instruction) < 10:
issues.append("问题过短")
if len(output) < 20:
issues.append("答案过短")
# 答案与原文的覆盖度
output_chars = set(output)
source_chars = set(source_text)
overlap = len(output_chars & source_chars) / max(len(output_chars), 1)
if overlap < 0.5:
issues.append("答案与原文覆盖度低,可能含编造内容")
# 问题是否为疑问句
question_markers = ['?', '?', '什么是', '如何', '为什么', '哪些', '怎样']
if not any(marker in instruction for marker in question_markers):
issues.append("问题不是疑问句")
return {
"valid": len(issues) == 0,
"issues": issues,
"overlap_score": overlap
}
def build_dataset(self, cleaned_articles, domain="医疗"):
"""构建完整微调数据集"""
dataset = []
for article in cleaned_articles:
if article["quality_score"] < 60:
continue
# 生成问答对
qa_pairs = self.generate_qa_pairs(
article["text"], domain=domain, num_pairs=3
)
for qa in qa_pairs:
# 质量校验
validation = self.validate_qa_pair(qa, article["text"])
if validation["valid"]:
dataset.append({
"instruction": qa["instruction"],
"output": qa["output"],
"source_url": article["url"],
"quality_score": article["quality_score"],
"overlap_score": validation["overlap_score"]
})
return dataset
# 使用示例
generator = InstructionGenerator("your_llm_api_key")
# 从清洗后的文章构建微调数据集
cleaned_articles = [
{"url": "https://example.com/medical/1",
"text": "阿司匹林是一种非甾体抗炎药...", "quality_score": 85}
]
dataset = generator.build_dataset(cleaned_articles, domain="医疗")
print(f"生成微调样本数: {len(dataset)}")
标注质量管控
自动标注的质量不可能100%可靠,必须有人工抽检环节:
| 质量管控环节 | 方法 | 抽检比例 |
|---|---|---|
| 答案准确性 | 人工核对答案与原文一致性 | 10%-20% |
| 问题多样性 | 检查问题是否重复或过于相似 | 5%-10% |
| 编造检测 | 核对答案中是否有原文不存在的内容 | 15%-20% |
| 领域覆盖 | 检查数据集是否覆盖领域主要子主题 | 全量统计 |
第四阶段:数据集格式化与划分
输出格式
微调数据集通常输出为JSONL格式,每行一条样本:
{"instruction": "阿司匹林的常见不良反应有哪些?", "output": "阿司匹林常见的不良反应包括胃肠道不适、出血风险增加等...", "source": "https://example.com/medical/1"}
{"instruction": "非甾体抗炎药的作用机制是什么?", "output": "非甾体抗炎药通过抑制环氧化酶活性...", "source": "https://example.com/medical/2"}
训练集/验证集划分
| 划分 | 比例 | 用途 |
|---|---|---|
| 训练集 | 80% | 模型微调训练 |
| 验证集 | 10% | 训练过程中评估 |
| 测试集 | 10% | 最终效果评估 |
划分时注意按主题分层抽样,确保每个子主题在训练集和验证集中都有分布。
完整流水线集成
class FineTuningDatasetBuilder:
"""微调数据集构建完整流水线"""
def __init__(self, proxy_api_key, llm_api_key):
self.collector = VerticalDataCollector(proxy_api_key)
self.cleaner = DataCleaner()
self.generator = InstructionGenerator(llm_api_key)
def build(self, url_list, domain="医疗", use_overseas=False,
country=None, area=None):
"""从URL列表构建完整微调数据集"""
raw_pages = []
cleaned_articles = []
# 阶段1:采集
print(f"[阶段1] 采集 {len(url_list)} 个页面")
for url in url_list:
if use_overseas:
ip_data = self.collector.get_overseas_ip(cty=country, count=1)
else:
ip_data = self.collector.get_domestic_ip(area=area, count=1)
for ip_info in ip_data.get("data", []):
page = self.collector.collect_page(
url, ip_info["ip"], ip_info["port"]
)
if page:
raw_pages.append(page)
break
time.sleep(3) # 请求间隔
# 阶段2:清洗
print(f"[阶段2] 清洗 {len(raw_pages)} 个页面")
for page in raw_pages:
result = self.cleaner.clean(page["html"], page["url"])
if result and result["quality_score"] >= 60:
cleaned_articles.append(result)
# 阶段3:标注
print(f"[阶段3] 标注 {len(cleaned_articles)} 篇文章")
dataset = self.generator.build_dataset(cleaned_articles, domain)
# 阶段4:格式化输出
print(f"[阶段4] 输出 {len(dataset)} 条微调样本")
return dataset
# 使用示例
builder = FineTuningDatasetBuilder(
proxy_api_key="abc123",
llm_api_key="your_llm_api_key"
)
# 采集国内医疗领域数据并构建微调数据集
medical_urls = [
"https://medical-journal.example.com/article/1",
"https://medical-journal.example.com/article/2",
"https://medical-journal.example.com/article/3",
]
dataset = builder.build(
url_list=medical_urls,
domain="医疗",
use_overseas=False,
area="110100" # 北京
)
# 保存为JSONL
import json
with open("medical_finetune_dataset.jsonl", "w", encoding="utf-8") as f:
for sample in dataset:
f.write(json.dumps(sample, ensure_ascii=False) + "\n")
print(f"数据集已保存: {len(dataset)} 条样本")
成本估算
以构建1万条微调样本为例(约需采集3000-5000个网页):
| 环节 | 资源消耗 | 推荐产品 | 成本特点 |
|---|---|---|---|
| 代理IP(国内采集) | 约5000次IP调用 | 短效动态IP(按量计费) | 0.0023元/IP起 |
| 代理IP(海外采集) | 约5000次IP调用 | 动态住宅IP(按流量计费) | 按流量,成本低 |
| LLM标注调用 | 约3000-5000次 | 按token计费 | 主要成本项 |
| 人工抽检 | 约1000-2000条 | 人工 | 按人天计算 |
代理IP成本在整体中占比很小,主要成本在LLM标注调用和人工抽检。如果采集量极大(10万+页面),海外采集建议用动态不限量套餐——不限流量、不限IP使用,按带宽计费,固定成本更可控。
常见问题
自动标注的准确率能到多少?
LLM自动标注的准确率通常在70%-85%之间,取决于源文本质量和LLM能力。必须配合15%-20%的人工抽检来保障数据集质量。建议对LLM标注结果做答案-原文覆盖度检测,覆盖度低于50%的样本大概率含编造内容,应重点审核。
清洗阶段最重要的环节是哪个?
去重。重复数据是微调数据集的头号杀手——它不仅浪费训练资源,还会导致模型对某些内容过拟合,在验证集上表现虚高但实际泛化能力差。建议做三层去重:URL去重、内容指纹去重(SimHash)、段落级去重。
采集垂直领域数据用国内IP还是海外IP?
取决于目标网站部署在哪个国家。国内医疗、法律、金融网站用国内短效动态IP,通过网帆代理API指定地区代码(area参数)和运营商(isp参数)。海外垂直领域网站用动态住宅IP,通过API指定洲代码(cnt参数)和国家代码(cty参数)。
一个垂直领域需要多少条微调样本?
取决于任务复杂度和模型基座大小。一般经验值:简单分类任务500-2000条,问答任务5000-20000条,复杂推理任务20000+条。质量比数量重要——5000条高质量样本的效果通常优于20000条低质量样本。
动态住宅IP和短效动态IP在数据集构建中怎么选?
采集海外垂直领域数据用动态住宅IP(9000万+节点,200+国家覆盖,原生住宅属性降低被识别风险)。采集国内垂直领域数据用短效动态IP(3000万+IP池,300+城市,99.83%纯净度,0.03秒延迟)。两者API调用方式不同,国内用area/isp参数,海外用cnt/cty参数。
海外代理在中国大陆能用吗?
不能。网帆代理海外动态住宅IP产品仅支持在境外网络环境下使用。如果团队在中国大陆,需要在境外服务器上部署采集脚本。
总结
垂直领域LLM微调数据集的构建,是一条从采集到清洗到标注的完整工程流水线。采集环节用代理IP分散请求、用目标国家本地IP访问本地化网站;清洗环节通过编码修复、标签剥离、正文提取、去重、质量评分五步把原始网页变成干净文本;标注环节用LLM自动构造指令-响应对,配合人工抽检保障质量。
网帆代理短效动态IP(国内,3000万+IP池,300+城市)和动态住宅IP(海外,9000万+节点,200+国家)为采集环节提供了可靠的代理基础设施。API调用格式简单清晰——国内用area/isp参数指定地域和运营商,海外用cnt/cty参数指定洲和国家。整个流水线中,代理IP成本占比很小,但它是整条流水线能跑起来的前提条件。