【AI测试系统】第6篇:需求扔进去,3 分钟出测试用例?AI测试系统的 RAG 知识增强实战

需求扔进去,3 分钟出测试用例?AI测试系统的 RAG 知识增强实战

这是AI测试系统系列的第6篇,总共会更30篇


后来我搭了一套 AI 系统,把需求文档扔进去,几分钟就能自动生成一批测试用例

但问题来了:AI 每次生成都是"从零开始",不知道历史用例、不知道项目规范、不知道常见坑,质量忽高忽低。

怎么让 AI "记住"之前的经验?

答案就是 RAG(检索增强生成)。我的系统里,RAG 不是概念,是实打实在跑的模块:

  • 每次生成用例前,先检索历史类似用例
  • 把相关知识注入 Prompt,AI 不再"从零开始"
  • 支持 MySQL 全文搜索(轻量)和 ChromaDB 向量检索(语义理解)
  • 自动优化上下文,防止 Token 爆炸

这篇文章不扯概念,直接拆架构 + 给源码 + 踩坑记录,适合想落地 RAG 的同学。


一、为什么测试系统需要 RAG?

1.1 传统测试用例生成的痛点

在没有 RAG 之前,测试用例生成面临以下问题:

  • 每次生成都是"从零开始":LLM 不知道历史用例,不知道项目规范,不知道常见坑
  • 生成的用例质量不稳定:可能遗漏重要场景、可能重复已存在的用例、可能不符合项目规范

核心问题: LLM 缺乏领域知识历史经验

1.2 RAG 如何解决这个问题?

RAG(Retrieval-Augmented Generation,检索增强生成)的核心思想:

复制代码
┌─────────────────────────────────────────────────────────┐
│                    用户查询                              │
│              "用户登录功能测试用例"                       │
├─────────────────────────────────────────────────────────┤
│              ① 检索(Retrieval)                         │
│   从知识库中检索相关知识:                                │
│   - 历史登录测试用例 × 3 条                              │
│   - 安全测试规范 × 1 条                                  │
│   - 常见 Bug 模式 × 1 条                                 │
├─────────────────────────────────────────────────────────┤
│              ② 增强(Augmented)                         │
│   将检索到的知识融入 Prompt:                            │
│   "请基于以下历史用例生成新的测试用例:..."               │
├─────────────────────────────────────────────────────────┤
│              ③ 生成(Generation)                        │
│   LLM 基于增强后的 Prompt 生成高质量用例                 │
└─────────────────────────────────────────────────────────┘

RAG 带来的价值:

  • 知识复用:历史用例、项目规范、常见 Bug 模式都被知识库收录
  • 质量提升:生成的用例更符合实际,减少重复和遗漏
  • 可追溯:每个生成的用例都可以追溯到原始知识来源
  • 持续进化:知识库随项目积累不断丰富

二、双模式检索架构

2.1 为什么需要两种检索模式?

不同规模的项目对检索系统的要求不同:

维度 MySQL 全文搜索 ChromaDB 向量检索
适用规模 < 10 万条数据 > 10 万条数据
检索精度 关键词匹配(精确) 语义相似度(模糊)
部署复杂度 低(MySQL 自带) 中(需额外部署)
资源消耗 低(CPU) 中(内存 + CPU)
查询速度 快(索引) 中(向量计算)
语义理解 有(Embedding)

设计决策: 系统默认使用 MySQL 全文搜索(轻量级),可通过配置切换到 ChromaDB(重量级)。

2.2 架构设计

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    RAGService 统一接口                      │
│              search(query, top_k, filters)                  │
├───────────────────────────┬─────────────────────────────────┤
│   MySQL 全文搜索模式       │   ChromaDB 向量检索模式         │
│   (use_chroma=False)      │   (use_chroma=True)             │
├───────────────────────────┼─────────────────────────────────┤
│ 1. MATCH() AGAINST()      │ 1. query_texts=[query]          │
│ 2. BOOLEAN MODE           │ 2. HNSW 索引                    │
│ 3. 相关性排序              │ 3. 余弦相似度                   │
│ 4. LIMIT top_k            │ 4. n_results=top_k              │
├───────────────────────────┼─────────────────────────────────┤
│ 优势:                    │ 优势:                          │
│ - 无需额外部署             │ - 语义理解能力强                │
│ - 查询速度快               │ - 支持跨语言检索                │
│ - 资源消耗低               │ - 支持模糊匹配                  │
└───────────────────────────┴─────────────────────────────────┘

关键设计:

  • 统一接口:调用者无需关心底层实现
  • 参数一致:query/top_k/filters 两种模式通用
  • 返回格式一致:统一返回 List[Dict],包含 content/metadata/relevance
  • 自动降级:ChromaDB 初始化失败时,自动回退到 MySQL

三、MySQL 全文搜索实现(核心代码)

3.1 全文搜索原理

MySQL 全文搜索基于 FULLTEXT 索引 ,使用 MATCH() AGAINST() 语法:

复制代码
-- 创建 FULLTEXT 索引(需提前执行,使用 ngram 分词器支持中文)
ALTER TABLE knowledge_base ADD FULLTEXT INDEX idx_content (content) WITH PARSER ngram;

-- 全文搜索查询
SELECT id, title, content,
       MATCH(content) AGAINST('登录测试' IN BOOLEAN MODE) AS relevance
FROM knowledge_base
WHERE MATCH(content) AGAINST('登录测试' IN BOOLEAN MODE)
ORDER BY relevance DESC
LIMIT 5;

BOOLEAN MODE 优势:

  • 支持布尔操作符:+(必须包含)、-(必须不包含)、*(通配符)
  • 不自动忽略常见词(stopwords)
  • 支持短语搜索:"用户登录"

3.2 核心实现代码

复制代码
# backend/app/rag/service.py

class RAGService:
    """
    RAG 服务

    支持两种检索模式:
    1. MySQL 全文搜索(轻量级,<10 万条数据)
    2. ChromaDB 向量检索(重量级,>10 万条数据)
    """

    def __init__(self, db: Session, use_chroma: bool = False):
        self.db = db
        self.use_chroma = use_chroma
        self.chroma_client = None

        if use_chroma:
            self._init_chroma()

    def _init_chroma(self):
        """初始化 ChromaDB 客户端"""
        try:
            import chromadb
            from chromadb.config import Settings

            self.chroma_client = chromadb.Client(Settings(
                chroma_db_impl="duckdb+parquet",
                persist_directory="./chroma_db"
            ))

            self.collection = self.chroma_client.get_or_create_collection(
                name="knowledge_base",
                metadata={"hnsw:space": "cosine"}
            )
            print("✅ ChromaDB 初始化完成")
        except Exception as e:
            print(f"❌ ChromaDB 初始化失败:{e}")
            self.use_chroma = False  # 自动降级到 MySQL

    def search(self, query: str, top_k: int = 5, filters: Optional[Dict] = None) -> List[Dict]:
        """统一检索接口"""
        if self.use_chroma and self.chroma_client:
            return self._search_chroma(query, top_k, filters)
        else:
            return self._search_mysql(query, top_k, filters)

    def _search_mysql(self, query: str, top_k: int = 5, filters: Optional[Dict] = None) -> List[Dict]:
        """MySQL 全文搜索"""
        # 1. 构建 WHERE 子句
        where_clauses = []
        if filters:
            for key, value in filters.items():
                where_clauses.append(f"{key} = '{value}'")

        where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"

        # 2. 全文搜索查询
        sql = text(f"""
            SELECT id, doc_type, title, content, metadata, quality_score,
                   MATCH(content) AGAINST(:query IN BOOLEAN MODE) AS relevance
            FROM knowledge_base
            WHERE {where_sql}
              AND MATCH(content) AGAINST(:query IN BOOLEAN MODE)
            ORDER BY relevance DESC, quality_score DESC
            LIMIT :limit
        """)

        # 3. 添加通配符(防止 SQL 注入)
        search_query = f"+{query}*".replace("'", "''")

        # 4. 执行查询
        results = self.db.execute(sql, {
            "query": search_query,
            "limit": top_k
        }).fetchall()

        # 5. 格式化结果
        return [
            {
                "id": r.id,
                "doc_type": r.doc_type,
                "title": r.title,
                "content": r.content,
                "metadata": json.loads(r.metadata) if r.metadata else {},
                "quality_score": float(r.quality_score),
                "relevance": float(r.relevance),
            }
            for r in results
        ]

关键细节:

  • f"+{query}*":添加 + 前缀(必须包含)和 * 通配符(前缀匹配)
  • .replace("'", "''"):转义单引号,防止 SQL 注入
  • ORDER BY relevance DESC, quality_score DESC:先按相关性排序,再按质量分排序

3.3 实际调用示例

复制代码
from app.rag.service import RAGService
from app.database import SessionLocal

db = SessionLocal()
rag = RAGService(db, use_chroma=False)

# 搜索"登录测试"相关知识
results = rag.search("登录测试", top_k=5)

# 输出示例:
# [
#   {
#     "id": 1,
#     "doc_type": "case",
#     "title": "用户登录功能测试用例",
#     "content": "测试场景:1. 正常登录 2. 密码错误 3. 账号锁定...",
#     "metadata": {"priority": "P0", "module": "用户管理"},
#     "quality_score": 0.9,
#     "relevance": 0.85
#   },
#   ...
# ]

db.close()

四、ChromaDB 向量检索(原理说明)

4.1 向量检索原理

向量检索将文本转换为高维向量 (Embedding),然后计算向量之间的余弦相似度

复制代码
文本: "用户登录功能测试"
  ↓ Embedding 模型
向量: [0.12, -0.45, 0.78, ..., 0.33] (768 维)

计算余弦相似度:
  similarity = cos(A, B) = (A·B) / (|A|×|B|)
  范围: -1 到 1(越接近 1 越相似)

4.2 ChromaDB 的优势

  • 内置 Embedding 模型(默认:all-MiniLM-L6-v2)
  • 内置 HNSW 索引(高效近似最近邻搜索)
  • 持久化存储(DuckDB + Parquet)
  • API 简洁(query_texts/n_results/where

4.3 核心调用逻辑

复制代码
# 向量检索(ChromaDB 自动将文本转换为向量)
results = self.collection.query(
    query_texts=[query],      # 查询文本(自动 Embedding)
    n_results=top_k,          # 返回数量
    where=where,              # 过滤条件
    include=["documents", "metadatas", "distances"]
)

# 距离转相似度(cosine 距离 = 1 - 相似度)
relevance = 1.0 - distance

4.4 双写策略

添加文档时,同时写入 MySQL 和 ChromaDB:

  • MySQL:持久化存储,支持结构化查询
  • ChromaDB:向量检索,支持语义搜索
  • 两者同步,保证数据一致性
  • ChromaDB 写入失败不影响 MySQL,优雅降级

五、ContextOptimizer:上下文优化器

5.1 反常识:RAG 检索回来的知识,不是越多越好

大多数人以为 RAG 的逻辑是"检索回来的知识越多,AI 生成质量越高"。实际上恰恰相反。

RAG 检索回来的知识如果太多太杂,直接放入 Prompt 会导致:

  • Token 爆炸:10 条知识 × 平均 500 字符 = 5000 字符 ≈ 1250 tokens,加上 Prompt 模板 + 用户查询 = 可能超过 LLM 上下文窗口
  • 冗余信息:多条知识内容重复、低相关性知识干扰生成、无关信息浪费 Token

ContextOptimizer 的三大职责:

  1. 只保留 Top-K 最相关句子
  2. 移除冗余信息
  3. 控制总 Token 数

5.2 核心实现

复制代码
# backend/app/services_pkg/context_optimizer.py

class ContextOptimizer:
    """RAG 上下文优化器"""

    # 中英文 Token 估算比例(1 token ≈ 2 字符)
    CHARS_PER_TOKEN=2

    def __init__(self, max_context_tokens: int = 4000):
        self.max_context_tokens = max_context_tokens
        self.max_context_chars = max_context_tokens * self.CHARS_PER_TOKEN

    def optimize_context(
        self,
        retrieved_chunks: List[Dict],
        max_tokens: int = None
    ) -> str:
        """
        优化检索回来的上下文

        流程:检索结果 → 按相关性排序 → 累加 Token → 超过阈值截断 → 去重 → 返回
        """
        if max_tokens:
            self.max_context_tokens = max_tokens
            self.max_context_chars = max_tokens * self.CHARS_PER_TOKEN

        if not retrieved_chunks:
            return ""

        # 1. 按相关性排序
        sorted_chunks = sorted(
            retrieved_chunks,
            key=lambda x: x.get('relevance_score', 0) or x.get('score', 0),
            reverse=True
        )

        # 2. 累加 Token,超过阈值则截断
        context_parts = []
        total_chars = 0

        for chunk in sorted_chunks:
            content = chunk.get('content', '')
            if not content:
                continue

            chunk_chars = len(content)

            # 检查是否超过阈值
            if total_chars + chunk_chars > self.max_context_chars:
                # 尝试截断这个 chunk
                remaining = self.max_context_chars - total_chars
                if remaining > 100:  # 至少保留 100 字符
                    truncated = self._smart_truncate(content, remaining)
                    context_parts.append(truncated)
                break

            context_parts.append(content)
            total_chars += chunk_chars

        # 3. 移除冗余信息
        optimized = self._remove_redundancy(context_parts)

        return '\n\n'.join(optimized)

    def _smart_truncate(self, text: str, max_chars: int) -> str:
        """智能截断文本,尽量保持句子完整"""
        if len(text) <= max_chars:
            return text

        truncated = text[:max_chars]

        # 优先在中文句子结束符(。!?)处截断
        sentence_endings = ['。', '!', '?', '!', '?', '\n']
        last_ending = -1

        for ending in sentence_endings:
            pos = truncated.rfind(ending)
            if pos > last_ending:
                last_ending = pos

        if last_ending > max_chars * 0.5:
            return truncated[:last_ending + 1] + '...'

        # 否则在单词边界截断(英文)
        last_space = truncated.rfind(' ')
        if last_space > max_chars * 0.5:
            return truncated[:last_space] + '...'

        return truncated + '...'

    def _remove_redundancy(self, chunks: List[str]) -> List[str]:
        """移除冗余信息"""
        if not chunks:
            return []

        # 1. 精确去重(完全相同的 chunk)
        seen = set()
        unique_chunks = []

        for chunk in chunks:
            normalized = chunk.strip()
            if normalized and normalized not in seen:
                seen.add(normalized)
                unique_chunks.append(normalized)

        # 2. 模糊去重(如果 chunk 太多)
        if len(unique_chunks) > 10:
            unique_chunks = self._fuzzy_deduplicate(unique_chunks)

        return unique_chunks

优化效果:

  • 检索 10 条知识(5000 tokens)→ 优化后 5 条(2500 tokens)
  • 使用率:62.5%(2500/4000)
  • 截断策略:优先在句子边界截断,保持语义完整

六、在 LangGraph 中的集成

6.1 调用流程

复制代码
用户查询 → RAGService.search() → 检索 5 条知识
  ↓
ContextOptimizer.optimize_context() → 优化到 3000 tokens
  ↓
更新 state["rag_context"] → 传递给 generate_node

6.2 analyze_node 中的 RAG 调用

复制代码
# backend/app/graphs/test_flow.py

async def analyze_node(state: TestState) -> dict:
    """分析节点 - 集成 RAG 检索"""
    print("🔍 [分析节点] 开始需求分析...")

    try:
        updates = {
            "status": "analyzing",
            "updated_at": time.time(),
            "requirements": [],
            "rag_context": [],  # RAG 检索结果
        }

        # 从消息中提取需求
        messages = state.get("messages", [])
        if messages:
            last_message = messages[-1].content if hasattr(messages[-1], 'content') else str(messages[-1])

            # 🚀 RAG 检索相关知识
            try:
                db = SessionLocal()
                rag = RAGService(db, use_chroma=False)
                rag_results = rag.search(last_message[:100], top_k=5)

                if rag_results:
                    # 🚀 使用 Context Optimizer 优化检索结果
                    optimizer = ContextOptimizer(max_context_tokens=4000)

                    chunks = [
                        {
                            "content": r.get('content', ''),
                            "doc_type": r.get('doc_type', ''),
                            "title": r.get('title', ''),
                            "relevance_score": r.get('relevance', 0.5),
                            "quality_score": r.get('quality_score', 0.0),
                        }
                        for r in rag_results
                    ]

                    optimized_context = optimizer.optimize_context(chunks, max_tokens=3000)

                    updates["rag_context"] = [
                        {"content": optimized_context, "tags": "optimized", "source": "rag"}
                    ]

                    stats = optimizer.get_context_stats(optimized_context)
                    print(f"✅ [分析节点] RAG 检索到 {len(rag_results)} 条知识,优化后 {stats['tokens']} tokens (使用率 {stats['usage_rate']}%)")
                else:
                    print("ℹ️ [分析节点] RAG 未找到相关知识")

                db.close()
            except Exception as e:
                print(f"⚠️ [分析节点] RAG 检索失败:{e}")

            # 提取功能点
            requirements = [
                f"功能点:{last_message[:100]}...",
                "边界条件:输入验证、异常处理",
                "性能要求:响应时间 < 2 秒",
            ]
            updates["requirements"] = requirements

        print(f"✅ [分析节点] 提取 {len(updates['requirements'])} 个需求点")
        return updates

    except Exception as e:
        print(f"❌ [分析节点] 错误:{e}")
        return {
            "status": "failed",
            "error": str(e),
            "updated_at": time.time()
        }

6.3 generate_node 中的知识增强

复制代码
async def generate_node(state: TestState) -> dict:
    """用例生成节点 - 集成 RAG 增强"""
    print("📝 [生成节点] 开始生成测试用例...")

    try:
        updates = {
            "status": "generating",
            "updated_at": time.time(),
            "test_steps": [],
        }

        # 获取需求和 RAG 知识
        requirements = state.get("requirements", [])
        rag_context = state.get("rag_context", [])  # 🚀 RAG 检索的知识

        # 🚀 使用 RAG 知识增强生成
        if rag_context:
            print(f"✅ [生成节点] 使用 {len(rag_context)} 条 RAG 知识增强生成")
            knowledge_context = "\n".join([
                f"- {k['content'][:100]}..." for k in rag_context
            ])
            print(f"   知识上下文:\n{knowledge_context[:200]}...")

        test_steps = []

        for i, req in enumerate(requirements, 1):
            step = {
                "step_id": f"step_{i}",
                "action": f"验证:{req[:50]}...",
                "expected": "符合预期",
                "priority": "P1" if i == 1 else "P2",
            }
            test_steps.append(step)

        updates["test_steps"] = test_steps
        print(f"✅ [生成节点] 生成 {len(test_steps)} 个测试步骤")
        return updates

    except Exception as e:
        print(f"❌ [生成节点] 错误:{e}")

设计原则:

  • RAG 检索失败不影响主流程
  • 即使没有 RAG 知识,系统仍能正常工作(只是质量可能下降)
  • 日志记录失败原因,便于排查

七、踩坑记录与解决方案

7.1 MySQL 全文搜索中文分词问题

问题: MySQL 默认使用空格分词,中文没有空格,导致全文搜索效果差。

解决方案: 使用 ngram 分词器。

复制代码
-- 建表时指定 ngram 分词器
CREATE TABLE IF NOT EXISTS knowledge_base (
    ...
    FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 已有表:使用迁移脚本
ALTER TABLE knowledge_base DROP INDEX ft_title_content;
ALTER TABLE knowledge_base ADD FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram;

实际效果:

  • ✅ ngram 分词器:支持中文分词,索引体积略大但可接受
  • ✅ 代码层通配符:f"+{query}*" 作为补充,提升短查询召回率

7.2 ChromaDB 初始化失败

问题: ChromaDB 依赖较多,初始化可能失败(缺少依赖、权限问题等)。

解决方案: 优雅降级,不影响核心功能。

复制代码
def _init_chroma(self):
    try:
        import chromadb
        # ... 初始化代码
        print("✅ ChromaDB 初始化完成")
    except Exception as e:
        print(f"❌ ChromaDB 初始化失败:{e}")
        self.use_chroma = False  # 自动降级到 MySQL

7.3 Token 爆炸问题

问题: RAG 检索回来的知识太多,直接放入 Prompt 导致 Token 超限。

解决方案: ContextOptimizer 自动优化。

复制代码
optimizer = ContextOptimizer(max_context_tokens=4000)
optimized_context = optimizer.optimize_context(chunks, max_tokens=3000)

7.4 向量检索性能问题

问题: ChromaDB 向量检索在数据量大时变慢。

解决方案:

  • 使用 HNSW 索引(ChromaDB 默认启用)
  • 限制 top_k 数量(不要设置太大)
  • 缓存热门查询结果(实际项目中可添加 Redis 缓存层)

八、总结

8.1 RAG 在测试系统中的价值

价值 说明
知识复用 历史用例、项目规范、常见 Bug 模式都被知识库收录
质量提升 生成的用例更符合实际,减少重复和遗漏
可追溯 每个生成的用例都可以追溯到原始知识来源
持续进化 知识库随项目积累不断丰富
双模式 MySQL 全文搜索(轻量)+ ChromaDB 向量检索(重量)
自动降级 ChromaDB 失败 → 回退到 MySQL,不影响主流程

8.2 与其他引擎的关系

复制代码
┌─────────────────────────────────────────────────────────┐
│                    LangGraph State Graph                 │
│  analyze_node → generate_node → execute_node → report  │
├─────────────────────────────────────────────────────────┤
│                    RAGService 桥接                       │
│   search() → MySQL/ChromaDB → 检索知识 → 优化上下文    │
├─────────────────────────────────────────────────────────┤
│              三大引擎协同工作                            │
│  RAG(知识增强) + MCP(工具调用) + Skills(技能扩展)  │
└─────────────────────────────────────────────────────────┘

在测试流程中的应用:

  • analyze_node:调用 RAG 检索历史用例,优化上下文后注入 state["rag_context"]
  • generate_node:读取 state["rag_context"],将知识融入测试步骤生成
  • RAGEnhancedGenerator:独立使用,基于 RAG 生成问答/用例

8.3 下一步

下一篇将介绍 RAG 在测试流程中的应用,详细讲解分析节点和生成节点如何协作,将 RAG 知识真正融入测试用例生成流程。


附录:完整代码索引

文件 说明
backend/app/rag/__init__.py RAG 模块导出
backend/app/rag/service.py RAGService + RAGEnhancedGenerator
backend/app/services_pkg/context_optimizer.py ContextOptimizer 上下文优化器
backend/app/graphs/test_flow.py analyze_node 中的 RAG 集成 + generate_node 中的知识增强
backend/app/models_fastapi.py KnowledgeBase 模型
init-db/01-schema.sql knowledge_base 表定义(ngram 分词器)
init-db/migrations/V001__add_ngram_fulltext_index.sql ngram 索引迁移脚本

关注"测试员周周",14 年测试老兵,持续分享 AI测试实战经验。

相关推荐
__log3 分钟前
弱网环境下的“生命线“:从AI流式响应到大文件上传的稳定性
开发语言·人工智能·php
糖果店的幽灵4 分钟前
【langgraph 从入门到精通graphApi 篇】LangGraphAPI 方式调用 - 初识与核心概念
数据库·人工智能·langgraph
念雨思6 分钟前
HarmonyOS AI 应用开发实战:短视频选题灵感 —— AI 驱动的内容创作引擎
人工智能·学习·华为·harmonyos·鸿蒙
FourAu8 分钟前
2026 前端突围指南:从 ESR 边缘渲染到封装 Web AI SDK,聊聊 AI 时代的职业进化
前端·人工智能
前端开发江鸟11 分钟前
模型返回了 JSON,为什么还不能调用工具?Runtime 的结构化输出校验链路
人工智能
Urbano12 分钟前
卫衣生产工艺科普与智能自动化设备应用解析
大数据·运维·人工智能
吴梓穆20 分钟前
Python 基础 正则表达式
python
kp0000028 分钟前
Prompt注入攻击(Prompt Injection Attack)
人工智能·安全·网络安全·信息安全·ai安全
巧克力男孩dd37 分钟前
Python超典型练习题(第一次作业)
开发语言·python·算法
这张生成的图像能检测吗38 分钟前
(论文速度)RE-NET递归事件网络:时态知识图上的自回归结构推理
人工智能·图神经网络·时序模型