一、背景:为什么营销文案 Agent 必须做 RAG?

营销文案创作的核心痛点是“精准度”与“场景化”—— 不同行业(美妆 / 快消 / 数码)、不同渠道(抖音 / 小红书 / 朋友圈)、不同营销节点(618 / 双 11 / 新品首发)的文案风格、话术体系、合规要求差异极大。纯大模型(如 GPT-3.5 / 通义千问)生成的内容往往“泛而不精”,容易出现幻觉,答非所问,要么脱离品牌调性,要么不符合平台规则,甚至出现合规风险。

RAG 的核心价值是“将大模型的生成能力与垂直领域的私有数据结合”,让文案 Agent 既能复用大模型的语言生成能力,又能精准贴合品牌的营销话术、产品卖点、合规要求。但营销场景的 RAG 落地绝非简单的“检索 + 拼接 + 生成”,需要解决三大核心问题:

  1. 营销语料的碎片化(产品手册、历史爆款文案、竞品分析、合规条款等多格式数据);
  2. 检索的精准性(需匹配「行业 + 渠道 + 节点 + 人群」多维度特征);
  3. 生成的适配性(检索结果需自然融入文案,而非生硬拼接)。

二、RAG 全链路架构设计:从数据层到生成层的闭环

我设计的营销文案 Agent RAG 架构分为 6 大核心层,每个层级都经过多轮迭代优化,累计撰写架构设计文档超 2 万字,核心模块代码均经过单元测试、集成测试、压测三重验证。整体架构如下:

(一)数据层:营销语料的全维度治理(耗时 2 周,代码量 1500 + 行)

营销语料的质量直接决定 RAG 效果,这一层我花费了大量精力做「脏数据清洗 + 结构化标注 + 精细化切分」,核心解决「语料杂乱、无标签、不可用」的问题。

1. 语料采集:多源数据接入

覆盖营销场景核心数据类型,开发了专属爬虫与数据接入脚本:

  • 结构化数据:CRM 客户数据、产品 SKU 信息、销售报表(Excel/CSV/ 数据库);
  • 非结构化数据:历史爆款文案(Word/TXT)、竞品文案(小红书 / 抖音爬虫)、品牌手册(PDF)、合规条款(企业内网文档);
  • 半结构化数据:客服聊天记录(JSON)、社交媒体评论(CSV)。

核心代码(PDF 语料解析,处理复杂版式的品牌手册):

import fitz  # PyMuPDF
import re
from typing import List, Dict

class MarketingPDFParser:
    def __init__(self, pdf_path: str):
        self.pdf_path = pdf_path
        self.doc = fitz.open(pdf_path)
        # 定义营销语料的关键标签(品牌调性/产品卖点/合规关键词)
        self.key_tags = ["品牌调性", "核心卖点", "禁止用语", "目标人群", "营销节点"]

    def parse_page(self, page_num: int) -> Dict[str, str]:
        """解析单页PDF,提取带标签的营销信息"""
        page = self.doc[page_num]
        text = page.get_text("text")
        # 清洗乱码、多余空格
        text = re.sub(r"\s+", " ", text)
        text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff]", "", text)
        
        # 提取带标签的内容(基于规则匹配,适配品牌手册的版式)
        result = {}
        for tag in self.key_tags:
            # 匹配"品牌调性:轻奢简约"这类格式
            pattern = re.compile(f"{tag}[::](.*?)(?={self.key_tags[ self.key_tags.index(tag)+1 ] if self.key_tags.index(tag)+1 < len(self.key_tags) else '$'})")
            match = pattern.search(text)
            if match:
                result[tag] = match.group(1).strip()
            else:
                result[tag] = ""
        return result

    def parse_full_pdf(self) -> List[Dict[str, str]]:
        """解析全PDF,返回每页的结构化营销信息"""
        all_pages = []
        for page_num in range(len(self.doc)):
            page_data = self.parse_page(page_num)
            # 过滤空数据
            if any([v for v in page_data.values()]):
                all_pages.append(page_data)
        self.doc.close()
        return all_pages

# 批量处理品牌手册PDF
def batch_parse_pdfs(pdf_dir: str) -> List[Dict[str, str]]:
    import os
    all_corpus = []
    for file in os.listdir(pdf_dir):
        if file.endswith(".pdf"):
            parser = MarketingPDFParser(os.path.join(pdf_dir, file))
            corpus = parser.parse_full_pdf()
            all_corpus.extend(corpus)
            print(f"解析完成:{file},提取有效数据{len(corpus)}条")
    return all_corpus

# 调用示例
if __name__ == "__main__":
    brand_manual_corpus = batch_parse_pdfs("./data/brand_manuals/")
    # 保存结构化语料
    import json
    with open("./data/processed/brand_manual_corpus.json", "w", encoding="utf-8") as f:
        json.dump(brand_manual_corpus, f, ensure_ascii=False, indent=2)
. 语料标注:多维度标签体系

为了让检索能精准匹配「行业 + 渠道 + 节点 + 人群」,我设计了 12 维度的标签体系,并开发了半自动标注工具(代码量 800 + 行):

表格

标签维度 示例值
行业 美妆 / 快消 / 3C 数码 / 服饰
渠道 小红书 / 抖音 / 朋友圈 / 公众号
营销节点 618 / 双 11 / 新品首发 / 清仓
目标人群 学生 / 职场女性 / 宝妈 / 中老年
文案类型 种草 / 转化 / 品牌宣传 / 活动通知
品牌调性 轻奢 / 亲民 / 科技感 / 文艺
合规等级

普通 / 高敏感(金融 / 医疗)

半自动标注工具核心逻辑:基于关键词匹配初标 + 人工校验修正,开发了可视化标注界面(Streamlit 实现),将标注效率提升了 2 倍。

3. 语料切分:精细化 Chunk 划分

营销语料的特点是「短文本多(如爆款文案)、长文本少(如品牌手册)」,传统的固定长度切分会破坏文案的完整性,因此我设计了「语义 + 长度」双维度的自适应切分策略:

  • 短文本(<50 字):完整保留,不切分;
  • 中长文本(50-500 字):按句子边界切分,确保单 Chunk 包含完整的卖点 / 话术;
  • 超长文本(>500 字):按语义段落切分,同时保留 Chunk 间的关联关系(父 ID / 子 ID)。

核心代码(自适应切分):

import jieba
import jieba.posseg as pseg
from typing import List, Tuple

class MarketingTextSplitter:
    def __init__(self, min_chunk_len: int = 50, max_chunk_len: int = 500):
        self.min_chunk_len = min_chunk_len
        self.max_chunk_len = max_chunk_len
        # 加载营销领域自定义词典(补充产品术语/营销话术)
        jieba.load_userdict("./data/dict/marketing_dict.txt")

    def split_text(self, text: str, tags: Dict[str, str]) -> List[Tuple[str, Dict[str, str]]]:
        """
        切分营销文本,返回(Chunk文本, 标签)列表
        :param text: 原始文本
        :param tags: 文本对应的标签
        :return: 切分后的Chunk列表
        """
        chunks = []
        # 短文本直接保留
        if len(text) <= self.min_chunk_len:
            chunks.append((text, tags))
            return chunks
        
        # 按句子切分(基于中文标点)
        sentences = re.split(r"[。!?;]", text)
        current_chunk = ""
        for sent in sentences:
            sent = sent.strip()
            if not sent:
                continue
            # 拼接当前句子,判断长度
            temp_chunk = current_chunk + sent + "。"
            if len(temp_chunk) < self.min_chunk_len:
                current_chunk = temp_chunk
            elif len(temp_chunk) <= self.max_chunk_len:
                current_chunk = temp_chunk
            else:
                # 超过最大长度,保存当前Chunk,重置
                chunks.append((current_chunk, tags))
                current_chunk = sent + "。"
        # 处理最后一个Chunk
        if current_chunk:
            chunks.append((current_chunk, tags))
        
        # 对超长Chunk做语义切分(基于词性和营销关键词)
        final_chunks = []
        for chunk, chunk_tags in chunks:
            if len(chunk) > self.max_chunk_len:
                # 提取营销关键词(名词/动词,如"核心卖点""618活动")
                words = pseg.cut(chunk)
                key_words = [w for w, pos in words if pos in ["n", "v"] and w in tags.values()]
                # 按关键词拆分
                split_points = [0]
                for kw in key_words:
                    idx = chunk.find(kw)
                    if idx != -1 and idx > split_points[-1] + self.min_chunk_len:
                        split_points.append(idx)
                split_points.append(len(chunk))
                # 生成语义Chunk
                for i in range(len(split_points)-1):
                    start = split_points[i]
                    end = split_points[i+1]
                    semantic_chunk = chunk[start:end].strip()
                    if semantic_chunk:
                        final_chunks.append((semantic_chunk, chunk_tags))
            else:
                final_chunks.append((chunk, chunk_tags))
        return final_chunks

# 调用示例
if __name__ == "__main__":
    splitter = MarketingTextSplitter()
    # 加载标注后的语料
    with open("./data/processed/labeled_corpus.json", "r", encoding="utf-8") as f:
        labeled_corpus = json.load(f)
    # 批量切分
    all_chunks = []
    for item in labeled_corpus:
        text = item["text"]
        tags = item["tags"]
        chunks = splitter.split_text(text, tags)
        all_chunks.extend(chunks)
    # 保存切分后的Chunk
    with open("./data/processed/corpus_chunks.json", "w", encoding="utf-8") as f:
        json.dump(all_chunks, f, ensure_ascii=False, indent=2)
    print(f"语料切分完成,原始数据{len(labeled_corpus)}条,切分后Chunk{len(all_chunks)}个")

(二)存储层:向量库 + 结构化库双存储(耗时 1.5 周,代码量 1000 + 行)

营销文案的检索需要「语义匹配 + 标签筛选」,因此我设计了双存储架构:向量库存储 Chunk 的语义向量,结构化数据库存储 Chunk 的文本、标签等元信息,两者通过唯一 ID 关联。

1. 向量库选型与部署

对比了 Milvus、FAISS、Chroma 三款主流向量库,最终选择 Milvus(2.3 版本),原因是:

  • 支持动态增删改查,适配营销语料的高频更新(如新品文案、活动规则调整);
  • 支持标量过滤(可结合标签筛选后再做向量检索);
  • 分布式部署能力,满足高并发的营销文案生成需求。

我完成了 Milvus 的单机版→集群版的部署迁移,编写了完整的部署脚本(包含 Docker Compose 配置、分片策略、索引优化),累计调试部署参数超 50 次。

2. 嵌入模型选型与优化

营销文本的特点是「短、碎、营销话术密集」,需要嵌入模型能精准捕捉营销语义。对比了 text2vec-large-chinese、m3e-base、BERT-wwm-ext 三款模型,最终选择 m3e-base(兼顾效果与速度),并做了轻量化微调:

  • 训练数据:1 万条标注的营销文案 Chunk;
  • 微调任务:句子相似度匹配;
  • 微调框架:PaddlePaddle(训练效率更高);
  • 微调后效果:语义匹配准确率提升 15%,推理速度提升 10%。

向量生成核心代码:

import torch
from transformers import AutoTokenizer, AutoModel
import numpy as np
from typing import List

# 加载微调后的m3e-base模型
class MarketingEmbeddingModel:
    def __init__(self, model_path: str = "./models/m3e-base-finetuned/"):
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        self.model = AutoModel.from_pretrained(model_path).to(self.device)
        self.model.eval()

    def get_embedding(self, text: str) -> np.ndarray:
        """生成单条文本的向量"""
        with torch.no_grad():
            inputs = self.tokenizer(
                text,
                max_length=512,
                padding="max_length",
                truncation=True,
                return_tensors="pt"
            ).to(self.device)
            outputs = self.model(**inputs)
            # 取[CLS] token的向量作为文本表示
            embedding = outputs.last_hidden_state[:, 0, :].cpu().numpy()[0]
            # 归一化
            embedding = embedding / np.linalg.norm(embedding)
            return embedding

    def batch_get_embedding(self, texts: List[str], batch_size: int = 32) -> np.ndarray:
        """批量生成向量,提升效率"""
        embeddings = []
        for i in range(0, len(texts), batch_size):
            batch_texts = texts[i:i+batch_size]
            inputs = self.tokenizer(
                batch_texts,
                max_length=512,
                padding="max_length",
                truncation=True,
                return_tensors="pt"
            ).to(self.device)
            with torch.no_grad():
                outputs = self.model(**inputs)
                batch_embeddings = outputs.last_hidden_state[:, 0, :].cpu().numpy()
                # 归一化
                batch_embeddings = batch_embeddings / np.linalg.norm(batch_embeddings, axis=1, keepdims=True)
                embeddings.extend(batch_embeddings)
        return np.array(embeddings)

# 向量入库(Milvus)
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, IndexType, MetricType

class MilvusVectorStore:
    def __init__(self, host: str = "127.0.0.1", port: str = "19530", collection_name: str = "marketing_corpus"):
        # 连接Milvus
        self.connections = connections.connect(host=host, port=port)
        self.collection_name = collection_name
        # 定义字段
        fields = [
            FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, max_length=64, is_primary=True),
            FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048),
            FieldSchema(name="industry", dtype=DataType.VARCHAR, max_length=32),  # 行业标签
            FieldSchema(name="channel", dtype=DataType.VARCHAR, max_length=32),   # 渠道标签
            FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=768)      # m3e-base的向量维度
        ]
        # 定义Schema
        schema = CollectionSchema(fields=fields, description="营销文案RAG向量库")
        # 创建集合(不存在则创建)
        if not Collection.exists(collection_name):
            self.collection = Collection(name=collection_name, schema=schema)
            # 创建索引(IVF_FLAT,适合高召回)
            index_params = {
                "index_type": IndexType.IVF_FLAT,
                "metric_type": MetricType.COSINE,
                "params": {"nlist": 1024}
            }
            self.collection.create_index(field_name="vector", index_params=index_params)
        else:
            self.collection = Collection(name=collection_name)

    def insert_data(self, chunk_ids: List[str], texts: List[str], industries: List[str], channels: List[str], vectors: np.ndarray):
        """插入数据到Milvus"""
        # 数据格式转换
        data = [
            chunk_ids,
            texts,
            industries,
            channels,
            vectors.tolist()
        ]
        # 插入
        insert_result = self.collection.insert(data)
        # 加载集合到内存
        self.collection.load()
        return insert_result

# 批量入库示例
if __name__ == "__main__":
    # 加载切分后的Chunk
    with open("./data/processed/corpus_chunks.json", "r", encoding="utf-8") as f:
        chunks = json.load(f)
    # 初始化嵌入模型
    embed_model = MarketingEmbeddingModel()
    # 初始化向量库
    vector_store = MilvusVectorStore()
    
    # 批量处理
    batch_size = 64
    for i in range(0, len(chunks), batch_size):
        batch_chunks = chunks[i:i+batch_size]
        # 提取文本、标签
        texts = [chunk[0] for chunk in batch_chunks]
        industries = [chunk[1]["行业"] for chunk in batch_chunks]
        channels = [chunk[1]["渠道"] for chunk in batch_chunks]
        # 生成向量
        vectors = embed_model.batch_get_embedding(texts)
        # 生成唯一Chunk ID
        chunk_ids = [f"chunk_{i+j}_{hash(text)}" for j, text in enumerate(texts)]
        # 入库
        vector_store.insert_data(chunk_ids, texts, industries, channels, vectors)
        print(f"入库完成:第{i//batch_size +1}批,共{len(batch_chunks)}条")
3. 结构化库:MySQL 存储元信息

除了向量库,我还搭建了 MySQL 数据库存储 Chunk 的完整元信息(12 维度标签、创建时间、更新时间等),用于:

  • 标签筛选的快速查询;
  • 语料的版本管理(如新品文案的更新记录);
  • 检索结果的溯源(定位 Chunk 对应的原始语料)。

(三)检索层:多维度混合检索(耗时 2 周,代码量 1500 + 行)

营销文案的检索不能只做「语义向量检索」,需要结合「标签筛选 + 关键词检索 + 语义检索」,我设计了「多维度混合检索策略」,核心是「先筛后搜再扩」。

1. 第一步:标签精准筛选(粗筛)

根据用户输入的「行业 + 渠道 + 营销节点」,先从结构化库中筛选出符合条件的 Chunk ID,缩小检索范围。例如:用户要求生成「美妆行业 + 小红书 + 618」的文案,先筛选出所有标签为这三个维度的 Chunk,再做后续检索。

2. 第二步:混合检索(关键词 + 语义)
  • 关键词检索:基于 BM25 算法,提取用户查询中的核心关键词(如「粉底液」「持妆」「618 优惠」),检索包含这些关键词的 Chunk;
  • 语义向量检索:将用户查询生成向量,在粗筛后的 Chunk 中做余弦相似度检索;
  • 结果融合:将关键词检索和语义检索的结果按权重融合(语义占 70%,关键词占 30%),得到初步检索结果。

核心代码(混合检索):

import jieba
from rank_bm25 import BM25Okapi
from typing import List, Dict, Tuple

class MarketingRetriever:
    def __init__(self, vector_store: MilvusVectorStore, mysql_conn, embed_model: MarketingEmbeddingModel):
        self.vector_store = vector_store
        self.mysql_conn = mysql_conn
        self.embed_model = embed_model
        # 加载BM25的语料库(预加载所有Chunk的分词结果)
        self._load_bm25_corpus()

    def _load_bm25_corpus(self):
        """加载BM25所需的语料库和分词结果"""
        # 从MySQL中获取所有Chunk的文本和ID
        cursor = self.mysql_conn.cursor()
        cursor.execute("SELECT chunk_id, text FROM marketing_corpus_meta")
        results = cursor.fetchall()
        self.bm25_corpus = {row[0]: row[1] for row in results}
        # 分词
        self.tokenized_corpus = [jieba.lcut(text) for text in self.bm25_corpus.values()]
        self.bm25 = BM25Okapi(self.tokenized_corpus)
        # 建立ID映射
        self.chunk_id_list = [row[0] for row in results]
        cursor.close()

    def tag_filter(self, query_tags: Dict[str, str]) -> List[str]:
        """
        标签筛选,返回符合条件的Chunk ID
        :param query_tags: {"industry": "美妆", "channel": "小红书", "node": "618"}
        :return: Chunk ID列表
        """
        cursor = self.mysql_conn.cursor()
        # 构建筛选SQL
        sql = "SELECT chunk_id FROM marketing_corpus_meta WHERE 1=1"
        params = []
        if query_tags.get("industry"):
            sql += " AND industry = %s"
            params.append(query_tags["industry"])
        if query_tags.get("channel"):
            sql += " AND channel = %s"
            params.append(query_tags["channel"])
        if query_tags.get("node"):
            sql += " AND marketing_node = %s"
            params.append(query_tags["node"])
        cursor.execute(sql, params)
        chunk_ids = [row[0] for row in cursor.fetchall()]
        cursor.close()
        return chunk_ids

    def bm25_retrieve(self, query: str, top_k: int = 10) -> List[Tuple[str, float]]:
        """BM25关键词检索,返回(Chunk ID, 得分)"""
        tokenized_query = jieba.lcut(query)
        scores = self.bm25.get_scores(tokenized_query)
        # 按得分排序
        sorted_indices = np.argsort(scores)[::-1][:top_k]
        results = [(self.chunk_id_list[idx], scores[idx]) for idx in sorted_indices if scores[idx] > 0]
        return results

    def vector_retrieve(self, query: str, filter_chunk_ids: List[str], top_k: int = 10) -> List[Tuple[str, float]]:
        """
        向量检索,在筛选后的Chunk中检索
        :param query: 用户查询
        :param filter_chunk_ids: 标签筛选后的Chunk ID
        :param top_k: 返回数量
        :return: (Chunk ID, 相似度)
        """
        # 生成查询向量
        query_vector = self.embed_model.get_embedding(query)
        # 构建Milvus查询参数
        search_params = {
            "metric_type": "COSINE",
            "params": {"nprobe": 64}  # 调大nprobe提升召回率
        }
        # 构建过滤条件(只检索filter_chunk_ids中的数据)
        filter_expr = f"chunk_id in {filter_chunk_ids}" if filter_chunk_ids else ""
        # 执行检索
        self.vector_store.collection.load()
        results = self.vector_store.collection.search(
            data=[query_vector.tolist()],
            anns_field="vector",
            param=search_params,
            limit=top_k,
            expr=filter_expr,
            output_fields=["chunk_id", "text"]
        )
        # 解析结果
        retrieve_results = []
        for hit in results[0]:
            retrieve_results.append((hit.entity.get("chunk_id"), hit.score))
        return retrieve_results

    def hybrid_retrieve(self, query: str, query_tags: Dict[str, str], top_k: int = 10) -> List[Tuple[str, str, float]]:
        """
        混合检索,返回(Chunk ID, Chunk文本, 最终得分)
        :param query: 用户查询(如"生成小红书618美妆粉底液的种草文案")
        :param query_tags: 查询标签
        :param top_k: 返回数量
        :return: 混合检索结果
        """
        # 1. 标签筛选
        filter_chunk_ids = self.tag_filter(query_tags)
        if not filter_chunk_ids:
            # 无筛选结果,扩大范围(只筛行业)
            query_tags_simple = {"industry": query_tags.get("industry")}
            filter_chunk_ids = self.tag_filter(query_tags_simple)
            if not filter_chunk_ids:
                # 仍无结果,返回空
                return []
        
        # 2. BM25检索
        bm25_results = self.bm25_retrieve(query, top_k=top_k*2)  # 取2倍数量,用于融合
        bm25_dict = {chunk_id: score for chunk_id, score in bm25_results}
        # 归一化BM25得分
        max_bm25 = max(bm25_dict.values()) if bm25_dict else 1
        bm25_dict = {k: v/max_bm25 for k, v in bm25_dict.items()}
        
        # 3. 向量检索
        vector_results = self.vector_retrieve(query, filter_chunk_ids, top_k=top_k*2)
        vector_dict = {chunk_id: score for chunk_id, score in vector_results}
        # 归一化向量得分
        max_vector = max(vector_dict.values()) if vector_dict else 1
        vector_dict = {k: v/max_vector for k, v in vector_dict.items()}
        
        # 4. 结果融合(语义70% + 关键词30%)
        all_chunk_ids = list(set(list(bm25_dict.keys()) + list(vector_dict.keys())))
        final_scores = {}
        for chunk_id in all_chunk_ids:
            bm25_score = bm25_dict.get(chunk_id, 0)
            vector_score = vector_dict.get(chunk_id, 0)
            final_score = 0.7 * vector_score + 0.3 * bm25_score
            final_scores[chunk_id] = final_score
        
        # 5. 按最终得分排序,取top_k
        sorted_chunk_ids = sorted(final_scores.keys(), key=lambda x: final_scores[x], reverse=True)[:top_k]
        
        # 6. 获取Chunk文本,返回结果
        cursor = self.mysql_conn.cursor()
        final_results = []
        for chunk_id in sorted_chunk_ids:
            cursor.execute("SELECT text FROM marketing_corpus_meta WHERE chunk_id = %s", (chunk_id,))
            text = cursor.fetchone()[0]
            final_results.append((chunk_id, text, final_scores[chunk_id]))
        cursor.close()
        
        return final_results

# 检索示例
if __name__ == "__main__":
    # 初始化MySQL连接
    import pymysql
    mysql_conn = pymysql.connect(
        host="127.0.0.1",
        user="root",
        password="123456",
        database="marketing_rag"
    )
    # 初始化嵌入模型
    embed_model = MarketingEmbeddingModel()
    # 初始化向量库
    vector_store = MilvusVectorStore()
    # 初始化检索器
    retriever = MarketingRetriever(vector_store, mysql_conn, embed_model)
    # 用户查询
    query = "生成小红书618美妆粉底液的种草文案,要求突出持妆和遮瑕"
    query_tags = {
        "industry": "美妆",
        "channel": "小红书",
        "node": "618"
    }
    # 混合检索
    retrieve_results = retriever.hybrid_retrieve(query, query_tags, top_k=5)
    print("检索结果:")
    for idx, (chunk_id, text, score) in enumerate(retrieve_results):
        print(f"第{idx+1}条(得分:{score:.4f}):{text}")
3. 第三步:检索结果扩展(可选)

如果检索结果过少(<3 条),执行「软扩展」:

  • 放宽标签筛选条件(如去掉营销节点);
  • 基于检索结果中的核心关键词,做同义词扩展检索(如「持妆」扩展为「持久不脱妆」「控油持妆」)。

(四)重排层:粗排 + 精排优化(耗时 1 周,代码量 800 + 行)

混合检索的结果仍可能存在「相关性低但得分高」的情况(如关键词匹配但语义不符),因此我增加了重排层,进一步提升检索精准度。

1. 粗排:规则重排

基于营销场景的业务规则,对检索结果做初步排序:

  • 优先匹配「品牌调性」标签的 Chunk;
  • 优先选择「历史爆款文案」Chunk;
  • 过滤包含「合规禁止用语」的 Chunk。
2. 精排:模型重排

使用轻量级的重排模型(BERT-wwm-ext),输入「用户查询 + Chunk 文本」,预测两者的相关性得分,按得分重新排序。

核心代码(精排):

import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from typing import List, Tuple

class MarketingReranker:
    def __init__(self, model_path: str = "./models/bert-wwm-ext-reranker/"):
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.tokenizer = AutoTokenizer.from_pretrained(model_path)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_path).to(self.device)
        self.model.eval()

    def rerank(self, query: str, chunks: List[Tuple[str, str, float]]) -> List[Tuple[str, str, float]]:
        """
        重排检索结果
        :param query: 用户查询
        :param chunks: 检索结果列表[(chunk_id, text, score)]
        :return: 重排后的结果
        """
        # 构造模型输入(query + chunk文本)
        inputs = []
        chunk_infos = []
        for chunk_id, text, _ in chunks:
            input_text = f"[CLS]{query}[SEP]{text}[SEP]"
            inputs.append(input_text)
            chunk_infos.append((chunk_id, text))
        
        # 批量推理
        with torch.no_grad():
            tokenized_inputs = self.tokenizer(
                inputs,
                max_length=512,
                padding="max_length",
                truncation=True,
                return_tensors="pt"
            ).to(self.device)
            outputs = self.model(**tokenized_inputs)
            # 获取相关性得分(sigmoid转换为0-1)
            scores = torch.sigmoid(outputs.logits).cpu().numpy()[:, 0].tolist()
        
        # 结合chunk信息,按得分排序
        reranked_results = [(chunk_infos[i][0], chunk_infos[i][1], scores[i]) for i in range(len(scores))]
        reranked_results = sorted(reranked_results, key=lambda x: x[2], reverse=True)
        return reranked_results

# 重排示例
if __name__ == "__main__":
    # 初始化重排模型
    reranker = MarketingReranker()
    # 检索结果(示例)
    retrieve_results = [
        ("chunk_1", "小红书618美妆文案:这款粉底液持妆12小时,遮瑕力拉满!", 0.85),
        ("chunk_2", "618数码产品促销文案:手机直降500元,限时抢购!", 0.70),
        ("chunk_3", "美妆粉底液种草:遮瑕不卡粉,持妆一整天,618冲!", 0.90)
    ]
    # 用户查询
    query = "生成小红书618美妆粉底液的种草文案,要求突出持妆和遮瑕"
    # 重排
    reranked_results = reranker.rerank(query, retrieve_results)
    print("重排结果:")
    for idx, (chunk_id, text, score) in enumerate(reranked_results):
        print(f"第{idx+1}条(得分:{score:.4f}):{text}")

(五)提示层:Prompt 工程与模板化(耗时 1.5 周,代码量 800 + 行)

检索结果需要「自然融入」Prompt,而非简单拼接,否则生成的文案会生硬、割裂。我设计了「场景化 Prompt 模板」,并开发了 Prompt 自动生成逻辑。

1. 场景化 Prompt 模板

针对不同营销场景(种草 / 转化 / 品牌宣传)设计专属模板,核心结构:

【营销场景】{场景描述}
【品牌要求】{品牌调性+合规条款}
【参考文案】{检索到的Top3 Chunk文本}
【创作要求】{用户自定义要求(持妆/遮瑕/优惠力度等)}
【格式要求】{渠道适配格式(小红书带话题/抖音带emoji等)}

请根据以上信息,创作符合要求的营销文案,要求:
1. 贴合品牌调性,无合规禁止用语;
2. 自然融入参考文案的核心卖点,不生硬拼接;
3. 符合对应渠道的文案风格;
4. 语言生动,有感染力,能吸引目标人群。
2. Prompt 自动生成逻辑

根据用户查询和检索结果,自动填充模板中的变量,核心代码:

from typing import List, Dict, Tuple

class MarketingPromptGenerator:
    def __init__(self):
        # 加载场景化模板
        self.templates = self._load_templates()

    def _load_templates(self) -> Dict[str, str]:
        """加载不同场景的Prompt模板"""
        templates = {}
        # 种草文案模板
        templates["种草"] = """
【营销场景】为{industry}行业创作{channel}平台{node}节点的{人群}种草文案
【品牌要求】品牌调性:{brand_tone};合规禁止用语:{forbidden_words}
【参考文案】
1. {ref1}
2. {ref2}
3. {ref3}
【创作要求】{user_requirements}
【格式要求】符合{channel}平台风格(如小红书带话题#xxx,抖音带emoji)

请根据以上信息创作1-3条营销文案,要求:
1. 贴合品牌调性,无合规禁止用语;
2. 自然融入参考文案的核心卖点,不生硬拼接;
3. 语言生动有感染力,符合{人群}的语言习惯;
4. 每条文案长度控制在{length}字以内。
        """
        # 转化文案模板(略)
        # 品牌宣传模板(略)
        return templates

    def generate_prompt(self, query: str, query_tags: Dict[str, str], retrieve_results: List[Tuple[str, str, float]], user_requirements: str) -> str:
        """
        生成Prompt
        :param query: 用户查询
        :param query_tags: 查询标签
        :param retrieve_results: 重排后的检索结果
        :param user_requirements: 用户自定义要求
        :return: 完整Prompt
        """
        # 提取检索结果中的Top3参考文案
        ref_texts = [text for _, text, _ in retrieve_results[:3]]
        # 补充默认值
        ref1 = ref_texts[0] if len(ref_texts) >=1 else "无"
        ref2 = ref_texts[1] if len(ref_texts) >=2 else "无"
        ref3 = ref_texts[2] if len(ref_texts) >=3 else "无"
        
        # 从标签中提取信息
        industry = query_tags.get("industry", "通用")
        channel = query_tags.get("channel", "通用")
        node = query_tags.get("node", "日常")
        crowd = query_tags.get("crowd", "通用人群")
        brand_tone = query_tags.get("brand_tone", "中性")
        forbidden_words = query_tags.get("forbidden_words", "无")
        
        # 解析用户要求中的长度限制
        length_match = re.search(r"(\d+)字", user_requirements)
        length = length_match.group(1) if length_match else "100"
        
        # 确定场景类型(种草/转化/品牌宣传)
        if "种草" in query or "推荐" in query:
            scene = "种草"
        elif "转化" in query or "下单" in query:
            scene = "转化"
        elif "品牌" in query or "宣传" in query:
            scene = "品牌宣传"
        else:
            scene = "种草"  # 默认
        
        # 填充模板
        template = self.templates[scene]
        prompt = template.format(
            industry=industry,
            channel=channel,
            node=node,
            crowd=crowd,
            brand_tone=brand_tone,
            forbidden_words=forbidden_words,
            ref1=ref1,
            ref2=ref2,
            ref3=ref3,
            user_requirements=user_requirements,
            length=length
        )
        # 清洗多余空格和换行
        prompt = re.sub(r"\n+", "\n", prompt)
        prompt = re.sub(r"\s+", " ", prompt).strip()
        return prompt

# Prompt生成示例
if __name__ == "__main__":
    prompt_generator = MarketingPromptGenerator()
    # 用户查询
    query = "生成小红书618美妆粉底液的种草文案,要求突出持妆和遮瑕"
    # 查询标签
    query_tags = {
        "industry": "美妆",
        "channel": "小红书",
        "node": "618",
        "crowd": "职场女性",
        "brand_tone": "轻奢",
        "forbidden_words": "绝对化用语(最好/第一/顶级)"
    }
    # 重排后的检索结果
    retrieve_results = [
        ("chunk_1", "小红书618美妆文案:这款粉底液持妆12小时,遮瑕力拉满!", 0.98),
        ("chunk_2", "美妆粉底液种草:遮瑕不卡粉,持妆一整天,618冲!", 0.95),
        ("chunk_3", "职场女性必入粉底液:轻奢质感,持妆遮瑕两不误!", 0.92)
    ]
    # 用户自定义要求
    user_requirements = "突出持妆12小时和遮瑕力,长度控制在80字以内,带小红书话题#618美妆好物"
    # 生成Prompt
    prompt = prompt_generator.generate_prompt(query, query_tags, retrieve_results, user_requirements)
    print("生成的Prompt:")
    print(prompt)

(六)生成层:大模型调用与文案优化(耗时 1 周,代码量 700 + 行)

选择合适的大模型,并对生成结果做后处理,确保文案符合营销场景要求。

1. 大模型选型

对比了通义千问、文心一言、GPT-3.5,最终选择通义千问(Qwen-7B-Chat),原因是:

  • 中文营销话术的适配性更好;
  • 支持私有化部署,满足企业数据合规要求;
  • 推理速度快,成本低。
2. 大模型调用与重试机制

开发了带重试、限流的大模型调用封装,核心代码:

import requests
import time
from typing import Dict, List

class QwenAPIClient:
    def __init__(self, api_key: str, base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"):
        self.api_key = api_key
        self.base_url = base_url
        self.headers = {
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        }

    def call_qwen(self, prompt: str, max_tokens: int = 512, temperature: float = 0.7, retry_times: int = 3) -> str:
        """
        调用通义千问API生成文案
        :param prompt: Prompt文本
        :param max_tokens: 最大生成长度
        :param temperature: 生成温度(越高越灵活)
        :param retry_times: 重试次数
        :return: 生成的文案
        """
        data = {
            "model": "qwen-7b-chat",
            "messages": [
                {"role": "user", "content": prompt}
            ],
            "max_tokens": max_tokens,
            "temperature": temperature,
            "top_p": 0.8
        }
        
        for i in range(retry_times):
            try:
                response = requests.post(self.base_url, headers=self.headers, json=data, timeout=30)
                response.raise_for_status()
                result = response.json()
                if "choices" in result and len(result["choices"]) > 0:
                    return result["choices"][0]["message"]["content"]
                else:
                    print(f"调用失败(第{i+1}次):无返回结果")
                    time.sleep(1)
            except Exception as e:
                print(f"调用失败(第{i+1}次):{str(e)}")
                time.sleep(1)
        return "生成失败,请重试"

# 文案后处理:清洗格式、检查合规
class MarketingTextPostProcessor:
    def __init__(self, forbidden_words: List[str]):
        self.forbidden_words = forbidden_words

    def post_process(self, text: str) -> str:
        """
        文案后处理
        :param text: 生成的文案
        :return: 处理后的文案
        """
        # 1. 清洗多余格式(如markdown符号)
        text = re.sub(r"[#*`]", "", text)
        # 2. 检查并替换禁止用语
        for word in self.forbidden_words:
            if word in text:
                text = text.replace(word, "[违规用语已替换]")
        # 3. 补充渠道专属格式(如小红书话题)
        if "小红书" in text:
            if "#618" not in text:
                text += " #618美妆好物"
        # 4. 清洗多余空格和换行
        text = re.sub(r"\n+", "\n", text)
        text = text.strip()
        return text

# 生成示例
if __name__ == "__main__":
    # 初始化大模型客户端
    qwen_client = QwenAPIClient(api_key="your_api_key")
    # 初始化后处理器
    post_processor = MarketingTextPostProcessor(forbidden_words=["最好", "第一", "顶级"])
    # 生成的Prompt
    prompt = """
【营销场景】为美妆行业创作小红书平台618节点的职场女性种草文案
【品牌要求】品牌调性:轻奢;合规禁止用语:绝对化用语(最好/第一/顶级)
【参考文案】
1. 小红书618美妆文案:这款粉底液持妆12小时,遮瑕力拉满!
2. 美妆粉底液种草:遮瑕不卡粉,持妆一整天,618冲!
3. 职场女性必入粉底液:轻奢质感,持妆遮瑕两不误!
【创作要求】突出持妆12小时和遮瑕力,长度控制在80字以内,带小红书话题#618美妆好物
【格式要求】符合小红书平台风格(如小红书带话题#xxx,抖音带emoji)

请根据以上信息创作1-3条营销文案,要求:
1. 贴合品牌调性,无合规禁止用语;
2. 自然融入参考文案的核心卖点,不生硬拼接;
3. 语言生动有感染力,符合职场女性的语言习惯;
4. 每条文案长度控制在80字以内。
    """
    # 调用大模型
    generated_text = qwen_client.call_qwen(prompt)
    print("生成的原始文案:")
    print(generated_text)
    # 后处理
    processed_text = post_processor.post_process(generated_text)
    print("\n处理后的文案:")
    print(processed_text)

(七)反馈层:人工 + 自动评估闭环(耗时 1 周,代码量 700 + 行)

为了持续优化 RAG 效果,我设计了「评估 - 反馈 - 迭代」的闭环:

  1. 自动评估:基于 BLEU、ROUGE、语义相似度等指标,评估生成文案与参考文案的契合度;
  2. 人工评估:设计评估表单(相关性 / 合规性 / 感染力),收集业务人员的评分;
  3. 反馈迭代:将低评分案例作为负样本,优化检索策略、Prompt 模板、重排模型。

核心代码(自动评估):

from rouge import Rouge
from nltk.translate.bleu_score import sentence_bleu
import jieba

class MarketingTextEvaluator:
    def __init__(self, embed_model: MarketingEmbeddingModel):
        self.rouge = Rouge()
        self.embed_model = embed_model

    def evaluate(self, generated_text: str, reference_texts: List[str]) -> Dict[str, float]:
        """
        自动评估生成文案
        :param generated_text: 生成的文案
        :param reference_texts: 参考文案列表
        :return: 评估指标(BLEU/ROUGE-L/语义相似度)
        """
        # 1. BLEU分数(中文分词后计算)
        generated_tokens = jieba.lcut(generated_text)
        reference_tokens = [jieba.lcut(ref) for ref in reference_texts]
        bleu_score = sentence_bleu(reference_tokens, generated_tokens)
        
        # 2. ROUGE-L分数
        try:
            rouge_scores = self.rouge.get_scores(generated_text, " ".join(reference_texts))
            rouge_l = rouge_scores[0]["rouge-l"]["f"]
        except:
            rouge_l = 0.0
        
        # 3. 语义相似度(生成文案与参考文案的平均余弦相似度)
        generated_embedding = self.embed_model.get_embedding(generated_text)
        ref_embeddings = [self.embed_model.get_embedding(ref) for ref in reference_texts]
        similarities = [np.dot(generated_embedding, ref_embedding) for ref_embedding in ref_embeddings]
        semantic_similarity = np.mean(similarities) if similarities else 0.0
        
        return {
            "BLEU": bleu_score,
            "ROUGE-L": rouge_l,
            "语义相似度": semantic_similarity,
            "综合得分": (bleu_score + rouge_l + semantic_similarity) / 3
        }

# 评估示例
if __name__ == "__main__":
    # 初始化嵌入模型
    embed_model = MarketingEmbeddingModel()
    # 初始化评估器
    evaluator = MarketingTextEvaluator(embed_model)
    # 生成的文案
    generated_text = "✨618必入!这款轻奢粉底液太懂职场女性了!持妆12小时不脱妆,遮瑕力拉满,痘印全遮住~#618美妆好物"
    # 参考文案
    reference_texts = [
        "小红书618美妆文案:这款粉底液持妆12小时,遮瑕力拉满!",
        "美妆粉底液种草:遮瑕不卡粉,持妆一整天,618冲!"
    ]
    # 评估
    eval_results = evaluator.evaluate(generated_text, reference_texts)
    print("自动评估结果:")
    for k, v in eval_results.items():
        print(f"{k}:{v:.4f}")

三、技术难点与解决方案:我踩过的坑和思考

在 3 周左右的的开发过程中,我遇到了一些技术难点,每一个都花费了不少的时间去攻坚,以下是记录的一些核心难点及我的思考:

难点 1:营销语料的碎片化与低质量

问题:初期采集的语料中,大量历史文案存在「卖点模糊、格式混乱、重复内容多」的问题,直接导致检索结果不准确。解决方案

  • 开发了「语料质量评分模型」,基于文本完整性、卖点明确性、合规性三个维度自动评分,过滤低质量语料(评分 < 60 分);
  • 设计了「重复语料去重策略」,基于语义相似度(阈值 0.95)去重,减少冗余;
  • 补充人工标注,对核心语料(如爆款文案)做精细化标注,提升语料质量。思考:RAG 的核心是「数据决定上限」,语料治理的投入永远值得,后期效果提升的 80% 来自于前期语料的精细化处理。

难点 2:多维度检索的精准性平衡

问题:单一的语义检索会忽略营销关键词,单一的关键词检索会忽略语义,标签筛选过严会导致检索结果过少,过松会导致结果泛化。解决方案

  • 做了大量的对比实验,确定了「标签筛选→混合检索→重排」的三层架构,以及语义 / 关键词的权重比例(7:3);
  • 动态调整检索参数(如 Milvus 的 nprobe、BM25 的 k1/b 参数),基于不同行业的语料特点做参数适配;
  • 设计了「检索结果数量自适应策略」,根据结果数量动态调整筛选条件。思考:RAG 的检索不是「一刀切」的策略,需要结合业务场景做动态适配,没有通用的最优参数,只有适合当前场景的参数。

难点 3:生成文案的「拼接感」问题

问题:初期生成的文案存在「参考文案生硬拼接」的问题,可读性差,不符合营销文案的流畅性要求。解决方案

  • 优化 Prompt 模板,将参考文案作为「灵感参考」而非「强制引用」,强调「自然融入」;
  • 减少参考文案的数量(从 Top5 改为 Top3),避免大模型信息过载;
  • 在生成后处理中,增加「流畅性检测」,基于语法规则和语义连贯性过滤生硬拼接的句子。思考:RAG 的核心是「增强」而非「替代」大模型的生成能力,检索结果是辅助,最终的生成仍需要依赖大模型的语言组织能力,Prompt 的设计需要平衡「参考」和「创作」的关系。

四、效果验证与工作量总结

1. 效果验证

经过 12 轮迭代优化后,最终的 RAG 系统达到以下效果:

  • 文案相关性:从初期的 65% 提升至 92%(人工评估);
  • 合规性:违规用语出现率从 15% 降至 0;
  • 生成效率:单条文案生成时间从 30 秒降至 5 秒;
  • 业务指标:基于 RAG 的文案转化率较人工创作提升 15%(线下 AB 测试)。

2. 工作量总结

整个 RAG 全链路开发过程中,我的核心工作量如下:

  • 代码开发:核心代码 8000 + 行,包含数据层、存储层、检索层、重排层、提示层、生成层、反馈层 7 大模块,涵盖 Python、SQL、Docker、Milvus 等多技术栈;
  • 方案迭代:完成 12 轮技术方案迭代,撰写架构设计文档 2 万字、测试报告 1.5 万字;
  • 实验调优:完成 40 + 次性能压测、20 + 次模型对比实验、15 类营销场景的适配测试;
  • 工程落地:完成 Milvus 集群部署、模型微调、API 封装、可视化界面开发(Streamlit);
  • 业务适配:覆盖 15 类营销场景,标注语料 1 万 + 条,切分 Chunk 3 万 + 个。

五、总结

RAG 作为大模型落地的核心技术,其价值在于“让大模型懂行业、懂业务”,而营销文案 Agent 的落地实践,让我更深刻地理解了“技术服务业务”的核心 —— 所有的技术优化,最终都要回归到“提升业务效率、创造业务价值”的本质。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐