检索增强生成RAG项目tools_02:ollama➕logging➕bm25
有了我们的Docker部署做先行军,后面我们就可以分块了解RAG项目中各个必不可少的tools,本篇主要讲的是ollama➕logging➕bm25!!!
前言
在构建RAG(检索增强生成)系统的过程中,我逐渐认识到一个事实:RAG系统就是被各种零散的工具组合起来的。今天,我们聚焦三个核心工具:ollama(本地大模型部署)、logging(日志监控)和BM25(传统检索算法)。
这篇文章记录了我的学习过程,包含代码实践、难点分析和深度讨论。
一、Ollama:本地大模型的部署与调用
1.1 为什么选择Ollama?
在RAG系统中,大模型负责最终的答案生成。Ollama让我们能够在本地运行开源模型,具有以下优势:
-
数据安全:不需要将敏感数据发送到云端
-
成本可控:无需按token付费
-
离线可用:不依赖网络连接
1.2 四种调用模式详解
import ollama
from ollama import Client
import requests
模式1:基础聊天模式
def chatMode():
response = ollama.chat(
model='qwen2.5:0.5b',
messages=[{'role': 'user', 'content': '为什么天空是蓝色的?'}]
)
print(response['message']['content'])
重难点分析:
-
model参数:需要提前通过ollama pull qwen2.5:0.5b下载模型 -
messages格式:遵循OpenAI的对话格式,支持system/user/assistant三种角色 -
返回值结构:
response['message']['content']才是模型输出内容
模式2:流式输出
def streamMode():
stream = ollama.chat(
model='qwen2.5:0.5b',
messages=[{'role': 'user', 'content': '为什么天空是蓝色的?'}],
stream=True,
)
for chunk in stream:
print(chunk['message']['content'], end='', flush=True)
重难点分析:
-
flush=True:强制刷新缓冲区,实现print实时输出 -
流式输出的本质:逐token返回,而不是等待完整响应,交互更加友好
-
用户体验:大模型响应慢时,流式输出能让用户看到"正在思考"的过程
思考:
问:流式输出和普通输出有什么区别?
答:普通模式是"等菜全部做好再端上来",流式模式是"边做边端,做好一道上一道"。对于大模型这种响应时间较长的场景,流式输出能显著提升用户体验。
模式3:远程调用
def remoteMode():
client = Client(host='http://127.0.0.1:11434')
response = client.chat(
model='qwen2.5:0.5b',
messages=[{'role': 'user', 'content': '为什么天空是蓝色的?'}]
)
print(response['message']['content'])
重难点分析:
-
默认端口:Ollama服务默认运行在
11434端口 -
远程调用场景:模型在GPU服务器上,应用在其他机器上
-
与本地调用的区别:只是多了网络传输开销
模式4:原始HTTP请求
def rawMode():
url = "http://127.0.0.1:11434/api/chat"
data = {
"model": "qwen2.5:0.5b",
"options": {"temperature": 0.5},
"stream": False,
"messages": [{"role": "system", "content": "你是谁?"}]
}
response = requests.post(url, json=data, timeout=60)
print(response.json()['message']['content'])
重难点分析:
-
temperature参数:控制输出的随机性,0-1之间,越低越确定 -
timeout=60:必须设置!否则网络问题会导致程序卡死 -
这种方式的优势:可以自定义更多参数(top_p、max_tokens等)
1.3 实践心得
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| chatMode | 简单问答 | 代码简洁 | 不支持流式 |
| streamMode | 需要实时反馈 | 用户体验好 | 处理稍复杂 |
| remoteMode | 分布式部署 | 灵活 | 有网络延迟 |
| rawMode | 高度定制 | 参数完整 | 代码冗长 |
二、Logging:RAG系统的可观测性
2.1 为什么需要日志?
在RAG系统中,一个请求可能经过:检索 → 重排 → 生成等多个环节。没有日志,你就像在黑暗中摸索。
2.2 四种日志配置详解
基础配置
def basicLog():
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("Example1")
logger.debug("调试信息") # 不会输出(级别不够)
logger.info("程序运行正常") # ✅ 会输出
logger.warning("注意问题") # ✅ 会输出
logger.error("发生错误") # ✅ 会输出
重难点分析:
-
日志级别:DEBUG < INFO < WARNING < ERROR < CRITICAL
-
basicConfig只能配置一次,后续调用basicConfig重新配置无效basicConfig()是一次性初始化工具,不是动态配置工具。可以考虑为根logger命名 logger 有自己的配置用自己的配置,没有的话继承自根logger
根logger只在第一次配置的适合生效
核心就是:继承 + 可覆盖 + 一次性初始化。
如果你需要动态调整日志级别、格式等,应该直接操作 logger 和 handler,而不是依赖多次调用
basicConfig。
带格式的日志
def withFormat():
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("Example2")
logger.debug("调试模式已开启")
格式化占位符说明:
| 占位符 | 含义 | 示例 |
|---|---|---|
%(asctime)s |
时间 | 2024-01-15 10:30:45,123 |
%(levelname)s |
日志级别 | INFO |
%(message)s |
日志内容 | 程序运行正常 |
%(name)s |
记录器名称 | Example2 |
%(filename)s |
文件名 | app.py |
输出到文件
def save2File():
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
filename='app.log',
filemode='a', # 'a':追加, 'w':覆盖
encoding='utf-8'
)
重难点分析:
-
filemode='a':追加模式,程序重启不会覆盖之前的日志 -
重要:一旦指定了
filename,控制台就不再显示日志 -
encoding='utf-8':处理中文日志必须设置
同时输出到文件和控制台
def consoleAndFile():
logger = logging.getLogger("Example4")
logger.setLevel(logging.DEBUG)
# 控制台处理器:只显示INFO及以上
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 文件处理器:记录所有DEBUG及以上
file_handler = logging.FileHandler('app.log', mode='a', encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
logger.addHandler(console_handler)
logger.addHandler(file_handler)
思考:
问:如果配置了写入文件,不单独配置显示控制台的话,控制台就不显示了是吧?
答:完全正确!Python logging的设计哲学是:当你明确要把日志写入文件时,说明你不需要在控制台看日志了(比如生产环境);而在开发调试时,通常只看控制台就够了。
这个讨论让我理解了logging的设计思想:显式优于隐式。
2.3 RAG系统中的日志实践
class RAGSystem:
def __init__(self):
self.logger = self._setup_logger()
def _setup_logger(self):
logger = logging.getLogger("RAG") #创建/获取一个命名 logger 实体
logger.setLevel(logging.DEBUG)
# 文件记录所有
fh = logging.FileHandler('rag.log')
fh.setLevel(logging.DEBUG)
# 控制台只记录重要信息
ch = logging.StreamHandler()
ch.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
fh.setFormatter(formatter)
ch.setFormatter(formatter)
logger.addHandler(fh)
logger.addHandler(ch)
return logger
def retrieve(self, query):
self.logger.info(f"检索查询: {query}")
# 检索逻辑
self.logger.debug(f"检索结果: {results}")
return results
三、BM25:传统检索算法

"""
TF-IDF = TF × IDF
TF = 词在文档中出现次数 / 文档总词数
IDF = log(总文档数 / 包含该词的文档数)
BM25 = IDF × (TF × (k1 + 1)) / (TF + k1 × (1 - b + b × 文档长度 / 平均长度))
IDF = log((总文档数 - 包含该词的文档数 + 0.5) / (包含该词的文档数 + 0.5))
"""

TF-IDF
-
✅ 简单、计算快、易理解
-
❌ 词频线性增长不合理(100次≠100倍相关)
-
❌ 不考虑文档长度
BM25
-
✅ 词频饱和(出现10次后增益递减)
-
✅ 长度归一化(短文档优先)
-
✅ 可调参数适配不同场景
-
❌ 计算稍复杂
BM25 = TF-IDF的进化版,解决了词频过饱和和文档长度偏差问题,是搜索引擎的默认排序算法。
3.1 为什么还要学BM25?
在向量检索盛行的今天,BM25仍然有它的价值:
-
可解释性强:关键词匹配,容易理解为什么返回这个结果
-
计算资源少:不需要GPU,纯CPU计算
-
作为Baseline:评估向量检索效果的基准
3.2 BM25核心代码解析
import jieba
from rank_bm25 import BM25L
class BM25Search():
def __init__(self, documents):
self.documents = documents
# 中文分词:这是关键步骤
self.tokenized_docs = [jieba.lcut(doc) for doc in documents]
self.bm25 = BM25L(self.tokenized_docs)
def search(self, query):
tokenized_query = jieba.lcut(query)
try:
scores = self.bm25.get_scores(tokenized_query)
best_idx = scores.argmax()
best_score = scores[best_idx]
best_doc = self.documents[best_idx]
return best_doc, best_score
except Exception as e:
return None, 0
3.3 重难点深度解析
为什么用jieba.lcut?
BM25是基于词袋模型的算法,需要先对中文进行分词。jieba.lcut返回的是分词列表:
import jieba
text = "我喜欢编程"
print(jieba.lcut(text)) # ['我', '喜欢', '编程']
为什么选择BM25L而不是BM25?
| 变体 | 特点 | 适用场景 |
|---|---|---|
| BM25 | 基础版本 | 标准场景 |
| BM25L | 处理长文档更好 | 文档长度差异大时 |
| BM25Plus | 处理短查询更好 | 查询词少时 |
异常处理的重要性
try:
scores = self.bm25.get_scores(tokenized_query)
except Exception as e:
return None, 0 # 返回None而不是崩溃
3.4 测试示例
documents = ["我喜欢编程", "编程很有趣", "我吃了一个苹果"]
bm25_model = BM25Search(documents)
query = "他从前喜欢编程"
result, score = bm25_model.search(query)
print(result) # "我喜欢编程"
print(score) # 0.98(相似度分数)

3.5 BM25 vs 向量检索的讨论
问:在RAG系统中,BM25和向量检索应该怎么选择?
答:不是选择问题,是结合问题。混合检索(Hybrid Search)同时使用BM25和向量检索,然后融合排序。BM25擅长精确关键词匹配,向量检索擅长语义匹配,两者互补。
这让我理解了为什么现代RAG系统通常采用混合检索策略。
四、三个工具的协同:RAG系统雏形
class SimpleRAG:
def __init__(self):
self.logger = self._setup_logger()
self.bm25 = BM25Search(documents)
self.llm_client = ollama.Client()
def query(self, question):
# 1. 日志记录
self.logger.info(f"用户问题: {question}")
# 2. BM25检索
doc, score = self.bm25.search(question)
self.logger.debug(f"检索得分: {score}, 文档: {doc}")
# 3. 构建Prompt
prompt = f"基于以下内容回答问题:\n{doc}\n\n问题:{question}"
# 4. 调用大模型生成
response = self.llm_client.chat(
model='qwen2.5:0.5b',
messages=[{'role': 'user', 'content': prompt}]
)
self.logger.info("生成完成")
return response['message']['content']
写在最后:RAG系统的学习就像上一趟快车,工具层出不穷。但掌握了核心工具和思想,就能以不变应万变。希望这篇文章能帮助你在RAG的学习道路上少走一些弯路。
如果你在实践过程中遇到问题,欢迎交流讨论!
更多推荐



所有评论(0)