【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测试实战经验。

相关推荐
Godspeed Zhao1 小时前
具身智能中的传感器技术40.2——事件相机0.2
人工智能·科技·数码相机·机器学习·事件相机
庞轩px1 小时前
大模型为什么会有“幻觉”——从训练方式到推理局限
人工智能·prompt·rag·大模型幻觉·engineering·训练方式
Empty-Filled1 小时前
AI Agent 测试入门:从回答问题到执行任务
网络·人工智能
AI玫瑰助手1 小时前
Python入门:Windows/macOS/Linux系统安装Python教程
windows·python·macos
链上杯子1 小时前
OpenAI 兼容 API:多厂商模型切换时要懂的端点、密钥与限流常识
人工智能
冬奇Lab1 小时前
一天一个开源项目(第92篇):OpenHands - 全能型开源 AI 软件工程师
人工智能·开源·agent
weixin_408099672 小时前
身份证OCR API怎么选?对比4款主流产品后,我选择了石榴智能(含Python/Java调用示例)
人工智能·ocr·文字识别·api接口·身份证ocr·石榴智能·ocr api
AI创界者2 小时前
FaceFusionFree 4.6 加速版实测:深度解决黑边与源识别痛点
人工智能
qcx232 小时前
【AI Agent通识九课】05 · AI 的红绿灯 — 长任务怎么管
人工智能·ai·agent·warp