1. 项目概述:基于MySQL+Redis+BM25的传统QA问答系统
十年前我刚入行做搜索系统时,RAG(Retrieval-Augmented Generation)这个概念还没出现,但解决问答需求的底层逻辑其实一脉相承。当时我们用MySQL存结构化数据、Redis做缓存、BM25算法做检索,搭建的问答系统现在看就是RAG的"前身"。这种架构至今仍在很多对实时性要求高的场景发挥作用,比如电商客服系统中的常见问题自动回复。
这套系统的核心价值在于:当用户输入问题时,能快速从知识库中检索最相关的答案。相比现在流行的基于大语言模型的方案,传统方案在响应速度、资源消耗和结果可控性上仍有独特优势。我曾用这个架构为一家医疗平台搭建症状问答系统,在2C端实现平均响应时间<200ms,准确率超85%的性能表现。
2. 系统架构设计解析
2.1 技术选型背后的逻辑
选择MySQL+Redis+BM25这个技术栈不是偶然的,每个组件都针对特定需求:
-
MySQL:存储结构化问答对的最佳选择。我们通常设计两个核心表:
sql复制CREATE TABLE questions ( id INT PRIMARY KEY AUTO_INCREMENT, content TEXT NOT NULL, vector TEXT -- 用于存储BM25计算所需的词频统计信息 ); CREATE TABLE answers ( id INT PRIMARY KEY, qid INT NOT NULL, content TEXT NOT NULL, FOREIGN KEY (qid) REFERENCES questions(id) ); -
Redis:承担三重角色:
- 缓存热点问答对(使用STRING类型)
- 存储用户搜索历史(使用LIST类型)
- 实现简单的会话状态管理(使用HASH类型)
-
BM25:比TF-IDF更适合长短不一的问答文本。其核心公式:
code复制score(D,Q) = Σ IDF(qi) * (f(qi,D)*(k1+1))/(f(qi,D)+k1*(1-b+b*|D|/avgdl))其中k1和b是调节参数(通常取1.2和0.75),|D|是文档长度,avgdl是平均文档长度。
2.2 数据流设计要点
系统处理query时的完整流程:
- 查询预处理:包括分词、停用词过滤、同义词扩展
- 缓存检查:先查Redis是否存在相同query的缓存结果
- BM25检索:未命中缓存时,从MySQL计算相似度
- 结果排序:按相关性得分+业务权重综合排序
- 缓存写入:将新结果写入Redis并设置TTL
重要提示:一定要在BM25计算前做query归一化处理,包括全半角转换、繁简体转换等。这是准确率提升的关键。
3. 核心模块实现细节
3.1 知识库构建实战
优质的知识库是系统的基础。我们的经验是:
-
数据清洗:
- 使用正则表达式处理特殊字符:
[\u4e00-\u9fa5a-zA-Z0-9]+ - 对问题进行聚类去重(可以用SimHash算法)
- 使用正则表达式处理特殊字符:
-
索引优化:
sql复制-- 对问题内容创建全文索引 ALTER TABLE questions ADD FULLTEXT INDEX ft_content(content); -- 定期执行统计信息更新 ANALYZE TABLE questions; -
向量预计算:
提前计算每个问题的词频向量并存储,避免实时计算开销。我们使用Python实现:python复制from collections import Counter import json def build_vector(text): words = jieba.cut(text) counter = Counter(words) return json.dumps(dict(counter))
3.2 BM25算法的工程实现
直接使用原版公式计算效率太低,我们的优化方案:
-
参数预计算:
python复制# 预计算文档长度和平均长度 total_length = sum(len(doc) for doc in corpus) avgdl = total_length / len(corpus) # 预计算IDF idf = {} for word in vocabulary: df = sum(1 for doc in corpus if word in doc) idf[word] = math.log((len(corpus) - df + 0.5) / (df + 0.5) + 1) -
批量计算优化:
python复制def batch_bm25(query, docs, k1=1.2, b=0.75): scores = [] for doc in docs: score = 0 for word in query: if word not in doc['vector']: continue tf = doc['vector'][word] score += idf[word] * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc['length'] / avgdl)) scores.append(score) return scores -
Redis缓存设计:
python复制def get_cached_answer(query): cache_key = f"qa:{hashlib.md5(query.encode()).hexdigest()}" result = redis_client.get(cache_key) return json.loads(result) if result else None def set_cache(query, answer, ttl=3600): cache_key = f"qa:{hashlib.md5(query.encode()).hexdigest()}" redis_client.setex(cache_key, ttl, json.dumps(answer))
4. 性能优化关键技巧
4.1 MySQL查询优化方案
-
索引策略:
- 对
questions.content创建前缀索引:INDEX idx_content(content(20)) - 对频繁使用的条件字段创建组合索引
- 对
-
查询优化:
sql复制-- 不好的写法 SELECT * FROM questions WHERE content LIKE '%头痛%'; -- 优化写法 SELECT * FROM questions WHERE MATCH(content) AGAINST('头痛' IN BOOLEAN MODE); -
连接池配置:
python复制# 使用SQLAlchemy配置 from sqlalchemy import create_engine engine = create_engine( 'mysql+pymysql://user:pass@host/db', pool_size=20, max_overflow=10, pool_timeout=30 )
4.2 Redis高级用法
-
内存优化:
- 对长文本使用压缩:
zlib.compress(text.encode()) - 设置合理的maxmemory-policy(通常用allkeys-lru)
- 对长文本使用压缩:
-
管道加速:
python复制with redis_client.pipeline() as pipe: for q in queries: pipe.get(f"qa:{hash(q)}") results = pipe.execute() -
Lua脚本原子操作:
lua复制-- 实现查询计数和缓存更新原子操作 local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('EXPIRE', KEYS[1], 86400) end redis.call('HSET', KEYS[2], ARGV[1], ARGV[2]) return count
5. 典型问题与解决方案
5.1 准确率提升技巧
-
同义词扩展:
python复制synonym_dict = { "电脑": ["计算机", "笔记本"], "死机": ["卡死", "无响应"] } def expand_query(query): words = jieba.cut(query) expanded = [] for word in words: expanded.append(word) if word in synonym_dict: expanded.extend(synonym_dict[word]) return list(set(expanded)) -
错别字处理:
- 使用编辑距离算法(如Levenshtein distance)
- 构建常见错别字映射表
-
结果重排序:
python复制def rerank(results, query): # 加入点击率权重 for r in results: r['score'] *= 1 + math.log1p(r['click_count']) # 加入时效性权重 if '最新' in query: results.sort(key=lambda x: -x['update_time']) return results
5.2 高并发场景应对
-
限流措施:
python复制# 使用Redis实现令牌桶 def is_allowed(user_id): key = f"rate:{user_id}" now = time.time() pipe = redis_client.pipeline() pipe.zremrangebyscore(key, 0, now - 60) pipe.zcard(key) pipe.zadd(key, {now: now}) pipe.expire(key, 60) _, count, _, _ = pipe.execute() return count <= 30 # 每分钟30次 -
降级方案:
- 当BM25计算超时时,返回缓存的热门问题
- MySQL不可用时,仅从Redis获取数据
-
异步处理:
python复制# 使用Celery处理耗时操作 @app.task def async_update_index(question_id): question = get_question_from_db(question_id) vector = build_vector(question.content) update_vector_in_db(question_id, vector)
6. 与现代RAG架构的对比
虽然现在流行基于LLM的RAG系统,但传统方案仍有其优势场景:
| 对比维度 | 传统方案 | 现代RAG |
|---|---|---|
| 响应速度 | <200ms | 500ms-2s |
| 硬件需求 | 普通服务器即可 | 需要GPU资源 |
| 结果可控性 | 完全可控 | 存在幻觉风险 |
| 知识更新 | 实时更新 | 需要重新embedding |
| 长尾问题 | 依赖规则覆盖 | 理解能力更强 |
| 开发成本 | 低 | 较高 |
在金融、医疗等对准确性和实时性要求高的领域,我们常采用混合架构:用传统方案处理高频简单问题,复杂问题再走RAG流程。这种组合在实际项目中能降低30%以上的计算成本。
