前言

大语言模型能够生成自然、连贯的回答,但在地方文旅、企业知识和专业资料问答中,仅依赖模型自身知识并不可靠。建筑年代、人物关系、景点沿革等信息一旦出现偏差,回答即使表达流畅,也很难真正投入使用。

本文基于 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组织生成最终答案。
在这里插入图片描述
最终回答主要包含三个部分:

  1. 晋祠从北魏到明清的历史沿革;
  2. 周柏、难老泉和宋代侍女像所代表的“晋祠三绝”;
  3. 晋祠在太原及山西历史文化中的地位。

本次运行中,检索耗时约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格式保存,每条数据至少包含questionanswer两个字段。例如:

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=1nk+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链路。

主要特点如下:

  1. 本地知识可维护: 文旅资料以独立知识库保存,更新内容不需要重新训练大模型;
  2. 语义与关键词互补: BGE-M3处理同义改写,BM25加强专有名称匹配;
  3. 融合过程可解释: 页面展示Top-5资料、RRF得分及两路排名;
  4. 回答模式可配置: 可根据网络、成本和部署要求选择本地或Qwen模式;
  5. 索引一致性校验: 知识库变化后能够识别旧索引,避免数据错位;
  6. 场景迁移成本较低: 更换知识库后,可扩展至博物馆、校园、企业资料和设备手册问答。

对于垂直领域问答,模型参数规模并不是唯一决定因素。知识内容是否可靠、检索是否准确、回答是否能够追溯,同样影响系统的实际效果。

本文实现的山西文旅RAG系统将 BGE-M3语义检索、BM25关键词检索、RRF融合排序和Qwen生成 组合到统一流程中,为本地知识库与大模型协同提供了一套清晰的实现方式。


如果本文对你有所帮助,欢迎点赞、收藏和关注。

关注 码上视觉,一起学习计算机视觉、深度学习与人工智能项目实践。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐