基于BGE-M3+BM25+RRF+Qwen的山西文旅RAG智能问答系统【Python+本地知识库+Streamlit界面】
文章目录
前言
大语言模型能够生成自然、连贯的回答,但在地方文旅、企业知识和专业资料问答中,仅依赖模型自身知识并不可靠。建筑年代、人物关系、景点沿革等信息一旦出现偏差,回答即使表达流畅,也很难真正投入使用。
本文基于 BGE-M3、BM25、RRF和Qwen 实现一套山西文旅RAG智能问答系统。项目将408条山西文旅问答整理为本地知识库,先通过语义检索和关键词检索寻找参考资料,再根据代码配置选择直接返回本地答案,或由Qwen结合Top-5资料生成完整回答。
系统主要实现以下功能:
- BGE-M3语义向量检索;
- jieba分词与BM25关键词检索;
- RRF双路召回融合;
- Top-5检索依据与排名展示;
- 本地知识库/Qwen两种回答模式;
- Streamlit可视化问答界面;
- 知识库、向量分布、命中率和检索延迟分析。
一、系统功能与效果展示
1. 系统主界面
系统采用Streamlit构建可视化界面,左侧为问题输入和问答操作区域,右侧展示当前检索策略、召回数量、回答模式和示例问题。
界面中的回答模式不由用户在网页上临时切换,而是通过项目配置统一控制。这样可以避免普通用户误操作,也便于根据实际部署环境决定是否调用外部大模型。
勾选“展示召回过程”后,页面会保留Top-5参考资料、RRF得分、语义排名和关键词排名。当回答结果不理想时,可以继续判断问题来自知识库、召回排序还是生成阶段。
2. 实际问答效果
以“晋祠有什么历史?”为例,系统先完成BGE-M3与BM25双路检索,通过RRF获得Top-5资料,再由Qwen组织生成最终答案。
最终回答主要包含三个部分:
- 晋祠从北魏到明清的历史沿革;
- 周柏、难老泉和宋代侍女像所代表的“晋祠三绝”;
- 晋祠在太原及山西历史文化中的地位。
本次运行中,检索耗时约2.27秒,大模型生成耗时约11.54秒。页面同时显示分词结果和Top-5资料来源,回答内容具备进一步核对的依据。
3. 本地知识库与Qwen双模式
系统支持两种回答方式:
| 回答模式 | 处理方式 | 适用场景 |
|---|---|---|
| 本地知识库 | 直接返回最相关的本地答案 | 离线环境、固定知识问答、低成本部署 |
| Qwen增强 | 将Top-5资料交给Qwen归纳生成 | 历史沿革、综合介绍、多资料整合 |
两种模式共享同一套知识库和混合检索流程。即使生成接口暂时不可用,本地检索结果仍然可以正常展示。
二、系统架构与技术选型
1. RAG整体流程
RAG的关键不是直接让大模型回答问题,而是在生成之前增加知识检索过程。本项目的完整链路如下图所示。

系统处理流程可以概括为:
用户问题
├─ BGE-M3语义检索
└─ BM25关键词检索
↓
RRF融合排序
↓
Top-5参考资料
├─ 本地知识回答
└─ Qwen增强生成
2. 主要开发环境
本文项目实际运行环境如下:
| 组件 | 版本/说明 |
|---|---|
| Python | 3.8.20 |
| PyTorch | 2.4.1 |
| Transformers | 4.46.3 |
| Streamlit | 1.40.1 |
| Pandas | 2.0.3 |
| NumPy | 1.24.4 |
| jieba | 0.42.1 |
| 语义模型 | BAAI/bge-m3 |
| 生成模型 | qwen-plus |
3. 项目结构
为避免数据处理、检索逻辑和页面代码相互混杂,项目按照功能拆分为独立模块:
shanxi-tourism-rag/
├── app.py # Streamlit可视化界面
├── data/
│ └── knowledge_base.csv # 山西文旅本地知识库
├── artifacts/
│ ├── dense_index.npz # 语义向量索引
│ └── dense_index.json # 索引元数据
└── src/shanxi_rag/
├── config.py # 环境变量与运行配置
├── data.py # 数据读取、清洗与校验
├── embeddings.py # BGE-M3文本编码
├── index_store.py # 向量索引保存与检索
├── retriever.py # BM25、语义检索与RRF融合
├── generation.py # Qwen资料增强回答
└── runtime.py # 模型、索引与检索器装配
这种结构便于单独更新知识库、替换语义模型或调整生成接口,同时不会影响其他模块。
三、本地知识库构建与数据分析
1. 知识库结构
本地知识库采用CSV格式保存,每条数据至少包含question和answer两个字段。例如:
question: 晋祠三绝是什么?
answer: 周柏、难老泉、宋代侍女像。
数据载入后,需要清理空值、去除重复问题并统一字符串格式。核心处理逻辑如下:
def load_knowledge_base(path, answer_column="answer"):
frame = pd.read_csv(path, encoding="utf-8-sig")
frame = frame.dropna(subset=["question", answer_column]).copy()
frame["question"] = frame["question"].astype(str).str.strip()
frame[answer_column] = frame[answer_column].astype(str).str.strip()
frame = frame[
(frame["question"] != "") &
(frame[answer_column] != "")
]
frame = frame.drop_duplicates(
subset=["question"], keep="first"
).reset_index(drop=True)
frame["final_answer"] = frame[answer_column]
return frame
除常规清洗外,项目还会为知识库问题计算SHA-256指纹。运行时如果检测到知识库已经修改、向量索引仍为旧版本,系统会提示重新构建索引,避免数据与向量错位。
2. 知识库统计结果
当前知识库共包含408条问答,覆盖平遥、晋祠、悬空寺、洪洞、皇城相府、云冈、五台山、应县木塔等景点和文化主题。

从图中可以得到以下信息:
- 问题平均长度为7.4字,主要集中在5~9字;
- 答案平均长度为13.2字,整体以简洁知识问答为主;
- 描述类问题数量最多;
- “其他”主题占比较高,知识库具有明显的长尾特征。
这类数据适合直接检索,但对于“晋祠有什么历史”这类综合问题,单条答案通常无法覆盖全部信息,因此需要在Top-5召回后进行多资料归纳。
四、BGE-M3+BM25+RRF混合检索
1. BGE-M3语义向量检索
关键词完全相同并不是语义相同的必要条件。例如:
知识库问题:悬空寺为何千年不倒?
用户问题:悬空寺为什么能在悬崖上保存这么久?
两句话的字面差异较大,但表达的是同一问题。BGE-M3负责将文本编码为向量,使系统能够召回同义改写和口语化提问。
本项目使用CLS位置向量,并在保存前进行L2归一化:
encoded = tokenizer(
texts,
padding=True,
truncation=True,
max_length=512,
return_tensors="pt",
)
with torch.inference_mode():
output = model(**encoded)
vectors = output.last_hidden_state[:, 0]
vectors = torch.nn.functional.normalize(vectors, p=2, dim=1)
归一化后,查询向量与知识库向量可以通过矩阵乘法计算余弦相似度:
scores = knowledge_vectors @ query_vector
indices = np.argsort(-scores, kind="stable")[:top_k]
2. BGE-M3向量可视化
将问题向量通过t-SNE降至二维后,可以观察不同景点主题的语义分布。

平遥、五台山、云冈、皇城相府、洪洞、悬空寺和晋祠等主题形成了较清晰的局部聚集,说明BGE-M3能够提取文旅问题中的主题信息。需要注意的是,t-SNE主要用于观察分布趋势,不等同于准确率指标。

同主题问题的平均余弦相似度为 0.754,不同主题问题的平均值为 0.337。两组分布具有明显差异,为语义召回提供了基础。
3. BM25关键词检索
语义检索能够理解句意,但面对景点名、建筑名和专有名词时,关键词仍然十分重要。例如“云冈石窟”和“龙门石窟”的问题句式可能非常接近,真正决定检索结果的是具体名称。
项目首先使用jieba对知识库问题和用户问题进行分词,再构建BM25索引:
tokenized_questions = [
jieba.lcut(question)
for question in knowledge_base["question"]
]
bm25 = BM25Okapi(tokenized_questions)
query_tokens = jieba.lcut(query)
bm25_scores = bm25.get_scores(query_tokens)
bm25_ids = np.argsort(-bm25_scores, kind="stable")[:pool]
BGE-M3负责召回“意思相近”的问题,BM25负责匹配“名称准确”的问题,两者分别解决语义表达和专有词识别问题。
4. RRF融合排序
BGE-M3输出余弦相似度,BM25输出关键词相关度,两种分数不在同一尺度上,直接相加容易引入额外的归一化问题。本项目使用RRF根据排名进行融合:
S c o r e ( d ) = ∑ i = 1 n 1 k + r a n k i ( d ) Score(d)=\sum_{i=1}^{n}\frac{1}{k+rank_i(d)} Score(d)=i=1∑nk+ranki(d)1
其中, r a n k i ( d ) rank_i(d) ranki(d)表示资料 d d d在第 i i i种检索方法中的排名, k k k为平滑参数,本项目设置为60。
RRF核心代码如下:
def reciprocal_rank_fusion(rankings, rrf_k=60):
scores = {}
for ranking in rankings:
for rank, row_id in enumerate(ranking, start=1):
scores[row_id] = scores.get(row_id, 0.0) + 1.0 / (
rrf_k + rank
)
return scores
fused = reciprocal_rank_fusion(
[dense_ids, bm25_ids],
rrf_k=60,
)
ordered_ids = sorted(
fused,
key=lambda row_id: (-fused[row_id], row_id),
)[:top_k]
如果一条资料在BGE-M3和BM25两路结果中都排名靠前,其融合分数会进一步提高;如果只在某一路中表现突出,也仍有机会进入Top-5。
5. 三种检索方法效果对比

在流程测试集中,BM25、BGE-M3和Hybrid RRF的Hit@1均为40%;Hybrid RRF的Hit@5达到80%,高于两个单路检索方法的60%。
这里使用的是小规模流程测试集,主要用于确认三种方法的执行和统计流程,不作为大规模检索基准。不过,从结果可以看到,双路融合能够增加正确资料进入Top-5的机会。
BM25只执行分词和倒排匹配,单次查询延迟最低;BGE-M3需要完成向量编码;Hybrid RRF还需要增加关键词召回和融合排序。测试中,混合检索与BGE-M3保持在相近耗时量级。
图中的延迟仅统计单次检索,不包含模型冷启动、网页渲染和Qwen生成时间,因此与完整问答页面显示的耗时不能直接比较。
五、Qwen资料增强回答
1. 构造有约束的提示内容
完成Top-5召回后,系统不会只把用户问题发送给Qwen,而是将参考资料一起组成提示内容:
def build_user_prompt(question, results):
blocks = []
for number, result in enumerate(results, start=1):
blocks.append(
f"【参考资料 {number}】\n"
f"相关问题:{result.question}\n"
f"内容:{result.answer}"
)
context = "\n\n".join(blocks)
return (
f"参考资料如下:\n\n{context}\n\n"
f"用户问题:{question}\n\n请作答。"
)
系统提示词要求模型严格依据参考资料回答,不补充资料之外的年代、票价、开放时间等事实。当召回资料不足时,模型需要明确说明知识库暂时无法提供完整信息。
生成阶段使用较低的temperature=0.2,减少不必要的随机扩写,并通过流式输出逐步显示回答。
2. 代码级回答模式开关
回答模式通过环境配置控制,而不是在网页中提供开关:
# 关闭:直接返回本地知识库答案
SHANXI_RAG_ENABLE_LLM=false
# 开启:使用Qwen根据Top-5资料生成回答
SHANXI_RAG_ENABLE_LLM=true
DASHSCOPE_API_KEY=
DASHSCOPE_MODEL=qwen-plus
API Key只从环境变量读取,不应写入Python代码或上传到公开仓库。
页面中的回答分支如下:
results = runtime.retriever.search(
query,
top_k=settings.top_k,
)
if not settings.enable_llm:
st.write(results[0].answer)
else:
generator = QwenGenerator(
settings.dashscope_api_key,
settings.dashscope_base_url,
settings.dashscope_model,
)
st.write_stream(generator.stream(query, results))
当生成服务调用失败时,检索过程和Top-5本地资料仍然保留,用户可以继续查看知识库答案。
六、项目特点与总结
本项目不是简单地在网页中接入一个聊天接口,而是完成了从本地知识组织到检索、融合、生成和可视化展示的完整RAG链路。
主要特点如下:
- 本地知识可维护: 文旅资料以独立知识库保存,更新内容不需要重新训练大模型;
- 语义与关键词互补: BGE-M3处理同义改写,BM25加强专有名称匹配;
- 融合过程可解释: 页面展示Top-5资料、RRF得分及两路排名;
- 回答模式可配置: 可根据网络、成本和部署要求选择本地或Qwen模式;
- 索引一致性校验: 知识库变化后能够识别旧索引,避免数据错位;
- 场景迁移成本较低: 更换知识库后,可扩展至博物馆、校园、企业资料和设备手册问答。
对于垂直领域问答,模型参数规模并不是唯一决定因素。知识内容是否可靠、检索是否准确、回答是否能够追溯,同样影响系统的实际效果。
本文实现的山西文旅RAG系统将 BGE-M3语义检索、BM25关键词检索、RRF融合排序和Qwen生成 组合到统一流程中,为本地知识库与大模型协同提供了一套清晰的实现方式。
如果本文对你有所帮助,欢迎点赞、收藏和关注。
关注 码上视觉,一起学习计算机视觉、深度学习与人工智能项目实践。
更多推荐

所有评论(0)