LLM微调数据集构建:垂直领域网页数据的自动化清洗与标注

更新时间:2026-07-30

垂直领域微调,卡在数据上

通用大模型在开放问答上已经很强了,但一旦丢进医疗、法律、金融这类垂直领域,幻觉率立刻飙升。原因不复杂——预训练语料里垂直领域的专业内容占比太低。要让模型在特定领域靠谱,微调是必经之路,而微调的效果上限由数据集质量决定。

问题就在这:公开的垂直领域微调数据集极少,质量参差不齐,而且不一定匹配你的业务场景。大多数团队最终得自己构建数据集。而自建数据集最耗时的环节不是采集,是清洗和标注——原始网页里混着导航栏、广告、模板文字、重复内容、乱码,把这些噪音洗掉,再把剩下的内容组织成模型能学的格式,工作量往往占整个数据集构建周期的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成本占比很小,但它是整条流水线能跑起来的前提条件。