简介:这份代码包面向希望将本地大模型与嵌入模型整合为知识库智能体的开发者,围绕Ollama部署、BGE-M3嵌入、Vllm加速、Dify集成及本地DeepSeek编排展开,适合具备一定AI工程基础、想打通模型部署与智能体应用链路的技术人员参考。资源共4个文件,包含md说明文档、inscode配置、html页面与gitignore忽略规则,压缩包约8KB,体量轻便,便于快速查阅与二次整理。目前已有198人学习下载。内容侧重Ollama安装配置、模型目录与端口调整、模型选择下载、集成测试及Dify报错排查等关键环节,并延伸至本地大模型编排智能体的思路与AI学习路径,可帮助读者理解部署流程、规避集成常见问题,并据此搭建可复用的知识库智能体方案。
1. 为什么本地 RAG 的检索质量,八成卡在 Embedding 这一环
很多人搭本地知识库,第一反应是选个大模型,觉得模型越强回答越准。实际跑起来才发现,模型答非所问的根因往往不在生成端,而在检索端——你喂给它的上下文本身就是错的。Ollama 负责把生成模型跑起来,BGE-M3 负责把文本变成向量,这两者对接得好不好,直接决定检索召回率。BGE-M3 是少数同时支持稠密、稀疏、多向量三种检索模式的 embedding 模型,中文语义匹配表现稳定,还能吃下 8192 token 的长文本。把它接到 Ollama 的本地推理链路上,意味着你不需要任何外部 API,就能在单机上跑通「文档入库 → 向量检索 → 上下文拼接 → 模型生成」的完整闭环。这套方案适合想在自己机器上搭私有知识库的开发者,也适合需要离线部署、对数据不出本机有硬要求的团队。下面从环境搭建讲到参数调优,再到踩坑排查,尽量把每一步都落到可复现的命令和代码上。
2. 把 BGE-M3 接进 Ollama:环境、拉取与最小验证
2.1 为什么选 Ollama 跑 BGE-M3 而不是自己写推理服务
自己用 transformers 加载 BGE-M3 当然可以,但你要处理模型权重下载、显存分配、batch 推理、并发请求排队这一堆事。Ollama 把这些都封装掉了,一条ollama pull就能把模型拉下来,它自动处理量化、显存映射和服务暴露。对于 embedding 模型来说,Ollama 的/api/embeddings接口足够用,返回的是标准 JSON 向量数组,直接喂给向量库就行。
常见做法是用 Ollama 同时托管生成模型和 embedding 模型,比如qwen2.5:7b做生成、bge-m3做向量化,两个模型共享一个 Ollama 服务端口。这样你的 RAG 链路里只有一个外部依赖,部署和排错都简单。
需要注意的是,Ollama 对 embedding 模型的支持和生成模型略有不同——它不会走 chat 模板,而是直接返回向量。所以你在代码里调用的接口路径是/api/embeddings而不是/api/generate,这个区别后面代码里会体现。
2.2 拉取模型与验证服务可用性
先确认 Ollama 已经装好并且服务在跑。Linux 下通常是 systemd 管理,macOS 下是后台进程。用下面命令检查:
# 检查 Ollama 服务状态和版本 ollama --version # 确认服务在监听(默认 11434 端口) curl -s http://localhost:11434/api/tags | head -c 200如果服务没起来,Linux 下执行systemctl start ollama,macOS 下直接打开 Ollama 应用即可。接下来拉取 BGE-M3:
# 拉取 BGE-M3 embedding 模型 ollama pull bge-m3 # 拉取一个生成模型备用(以 qwen2.5:7b 为例) ollama pull qwen2.5:7b拉完之后验证 embedding 接口是否正常返回向量:
# 调用 embeddings 接口,传入一段中文文本 curl -s http://localhost:11434/api/embeddings -d '{ "model": "bge-m3", "prompt": "向量检索的核心是把文本映射到语义空间" }' | python3 -c " import sys, json data = json.load(sys.stdin) vec = data['embedding'] print(f'向量维度: {len(vec)}') print(f'前5个值: {vec[:5]}') "正常输出应该是向量维度: 1024,前几个值是浮点数。如果返回空数组或者报错model not found,说明模型没拉全,重新ollama pull bge-m3即可。如果返回维度不是 1024,检查是不是拉成了别的模型——BGE-M3 的稠密向量维度固定是 1024。
提示:Ollama 的 embeddings 接口对单次请求的文本长度有限制,超长文本会被截断。BGE-M3 本身支持 8192 token,但 Ollama 封装层可能会在更短的长度截断,后面第 4 章会讲怎么处理长文档。
2.3 用 Python 封装一个可复用的 Embedding 客户端
直接 curl 只能做验证,实际项目里需要一个稳定的客户端。下面这个封装处理了超时、重试和批量请求:
import requests import time from typing import List class OllamaEmbedding: def __init__(self, model: str = "bge-m3", base_url: str = "http://localhost:11434"): self.model = model self.base_url = base_url self.endpoint = f"{base_url}/api/embeddings" def embed(self, text: str, retries: int = 3) -> List[float]: """单条文本向量化,带重试""" for attempt in range(retries): try: resp = requests.post( self.endpoint, json={"model": self.model, "prompt": text}, timeout=30 # 长文本可能推理较慢,给足超时 ) resp.raise_for_status() return resp.json()["embedding"] except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: if attempt == retries - 1: raise RuntimeError(f"Embedding 请求失败: {e}") time.sleep(1 * (attempt + 1)) # 退避重试 def embed_batch(self, texts: List[str]) -> List[List[float]]: """批量向量化,逐条调用(Ollama 不支持单次多文本)""" return [self.embed(t) for t in texts]这里有几个设计决策值得说明。第一,Ollama 的 embeddings 接口一次只接受一个prompt字符串,不支持传数组,所以批量处理只能在客户端循环。第二,超时设了 30 秒,因为长文本(比如 2000 字以上)在 CPU 推理时可能需要好几秒。第三,重试用了简单的线性退避,避免服务刚启动时偶发的连接拒绝。
参数方面,model字段必须和ollama list里显示的模型名完全一致,大小写敏感。base_url如果 Ollama 跑在别的机器上,改成对应 IP 就行,但要注意 Ollama 默认只监听 localhost,远程访问需要改OLLAMA_HOST环境变量。
3. 从文本到向量库:入库、检索与上下文拼接的完整链路
3.1 文档切分策略:BGE-M3 的长文本能力怎么用才不浪费
BGE-M3 支持 8192 token,但并不意味着你应该把整篇文档直接塞进去。原因有两个:一是 Ollama 封装层可能提前截断,二是过长的文本向量会稀释关键信息的权重,检索时反而不准。常见做法是按语义段落切分,每段控制在 300 到 500 字之间,段落之间保留 50 字左右的重叠区,避免边界处的信息丢失。
切分工具可以用 LangChain 的RecursiveCharacterTextSplitter,也可以自己写一个基于标点符号的简单切分器。下面是一个不依赖 LangChain 的最小实现:
import re from typing import List def split_text(text: str, chunk_size: int = 400, overlap: int = 50) -> List[str]: """按中文标点切分文本,控制每段长度和重叠""" # 先按段落分 paragraphs = re.split(r'\n\s*\n', text) chunks = [] for para in paragraphs: para = para.strip() if not para: continue # 段落本身不超长就直接用 if len(para) <= chunk_size: chunks.append(para) else: # 超长段落按句号、问号、感叹号切 sentences = re.split(r'(?<=[。!?])', para) current = "" for sent in sentences: if len(current) + len(sent) <= chunk_size: current += sent else: if current: chunks.append(current) # 重叠区:取上一段末尾 overlap 个字符 current = current[-overlap:] + sent if current else sent if current: chunks.append(current) return chunks这个切分器的逻辑是:先按空行分段,短段落直接保留,长段落再按句号切。重叠区的处理是在切分点回退 50 个字符,保证跨段落的语义不会被硬切断。chunk_size设 400 是一个经验值——太小会导致检索碎片化,太大则向量语义模糊。如果你的文档以短句为主(比如 FAQ),可以降到 200;如果是技术文档,可以升到 600。
3.2 用 ChromaDB 做向量存储与相似度检索
向量库的选择很多,ChromaDB 的优势是零配置、纯 Python、支持持久化,适合本地 RAG 场景。安装就一行pip install chromadb。下面是入库和检索的完整代码:
import chromadb from chromadb.config import Settings # 初始化持久化客户端,数据存在本地目录 client = chromadb.PersistentClient(path="./chroma_db") # 创建或获取集合,使用余弦相似度 collection = client.get_or_create_collection( name="knowledge_base", metadata={"hnsw:space": "cosine"} # 指定余弦距离 ) # 假设 embedder 是上一章的 OllamaEmbedding 实例 from embedding_client import OllamaEmbedding embedder = OllamaEmbedding() def add_documents(docs: List[str], ids: List[str]): """将切分后的文档块向量化并入库""" vectors = embedder.embed_batch(docs) collection.add( embeddings=vectors, documents=docs, ids=ids ) def search(query: str, top_k: int = 5) -> List[dict]: """检索最相似的 top_k 个文档块""" query_vec = embedder.embed(query) results = collection.query( query_embeddings=[query_vec], n_results=top_k, include=["documents", "distances"] ) # 整理返回格式 hits = [] for doc, dist in zip(results["documents"][0], results["distances"][0]): hits.append({"text": doc, "score": 1 - dist}) # 余弦距离转相似度 return hits关键参数说明:hnsw:space设成cosine是因为 BGE-M3 的向量在训练时用的就是余弦相似度,换成欧氏距离会导致排序偏差。n_results一般设 3 到 5,太少可能漏掉关键信息,太多会引入噪声干扰生成模型。include里加上distances是为了后续做阈值过滤——相似度低于 0.3 的结果基本可以丢掉。
入库时ids必须唯一,可以用f"doc_{i}"这种格式,也可以用文档内容的哈希值。如果重复入库同一个 id,ChromaDB 会覆盖旧数据,不会报错,这个行为在增量更新时要注意。
3.3 把检索结果拼成 Prompt 喂给生成模型
检索到相关文档块之后,需要把它们拼成上下文,和用户问题一起送给生成模型。拼接方式直接影响回答质量,常见做法是按相似度排序,取前 N 个,用分隔符隔开:
def build_prompt(query: str, hits: List[dict], max_context_len: int = 2000) -> str: """将检索结果拼成上下文,控制总长度""" context_parts = [] total_len = 0 for hit in hits: text = hit["text"] if total_len + len(text) > max_context_len: break context_parts.append(text) total_len += len(text) context = "\n---\n".join(context_parts) prompt = f"""基于以下参考资料回答问题。如果资料中没有相关信息,直接说不知道。 参考资料: {context} 问题:{query} 回答:""" return prompt这里max_context_len设 2000 字是一个保守值,因为生成模型本身也有上下文窗口限制,留出空间给系统提示和对话历史。分隔符用\n---\n是为了让模型能区分不同来源的文档块。如果检索结果相似度普遍偏低(比如都低于 0.4),可以在 prompt 里加一句「参考资料可能不完整」,让模型更谨慎地作答。
调用生成模型的代码:
def generate(prompt: str, model: str = "qwen2.5:7b") -> str: resp = requests.post( "http://localhost:11434/api/generate", json={"model": model, "prompt": prompt, "stream": False}, timeout=120 ) return resp.json()["response"]整个链路串起来就是:用户提问 → embed → ChromaDB 检索 → 拼 prompt → Ollama 生成 → 返回答案。每一步的延迟加起来,在普通笔记本上大概 3 到 8 秒,主要耗时在生成模型推理上。
4. 避坑与排查:BGE-M3 对接 Ollama 时最容易翻车的五个地方
4.1 向量维度对不上导致入库报错
现象:调用collection.add()时抛出Dimension mismatch异常,提示期望 1024 维但收到 768 维。
原因:Ollama 里可能同时存在多个 embedding 模型,代码里指定的模型名和实际拉取的模型不一致。比如bge-m3和nomic-embed-text的维度不同,前者 1024,后者 768。
解决:先用ollama list确认模型名,再在代码里硬编码维度校验:
EXPECTED_DIM = 1024 vec = embedder.embed("测试") assert len(vec) == EXPECTED_DIM, f"维度异常: {len(vec)},检查模型名是否正确"4.2 长文本被静默截断,检索时找不到关键信息
现象:一篇 3000 字的文档入库后,检索其中后半部分的内容时完全召不回。
原因:Ollama 的 embeddings 接口对输入长度有隐式限制,超过部分被截断,但不会报错。BGE-M3 本身支持 8192 token,但 Ollama 封装层可能在 512 或 1024 token 处截断。
解决:入库前先做长度检查,超过阈值的文本强制切分:
def safe_embed(text: str, max_chars: int = 1500) -> List[float]: if len(text) > max_chars: # 超长文本取前 max_chars 个字符,或调用切分逻辑 text = text[:max_chars] return embedder.embed(text)更稳妥的做法是在切分阶段就把 chunk_size 控制在 500 字以内,从源头避免截断。
4.3 首次请求超时,后续正常
现象:Ollama 服务刚启动后第一次调用 embeddings 接口,等了十几秒才返回,甚至超时。
原因:模型首次加载需要从磁盘读入显存,冷启动时间取决于模型大小和磁盘速度。BGE-M3 量化后大约 1.2GB,冷启动通常 5 到 10 秒。
解决:在服务启动后主动发一次预热请求,不要等用户请求来触发:
def warmup(): try: embedder.embed("预热") print("Embedding 模型预热完成") except Exception as e: print(f"预热失败: {e}")把warmup()放在应用启动脚本里,用户第一次请求时就不会感知到冷启动延迟。
4.4 相似度分数普遍偏高,检索结果区分度差
现象:不管问什么,返回的 top 5 文档相似度都在 0.7 以上,看起来都相关,但实际有用的只有一两个。
原因:BGE-M3 的向量在余弦空间里分布比较集中,尤其是同一领域的文档,相似度天然偏高。另外,如果所有文档块都来自同一篇长文,它们之间的语义差异本身就小。
解决:不要只看绝对分数,要看分数差距。取 top 10 结果,观察分数拐点,在拐点处截断。也可以引入重排序模型做二次筛选,但那是另一个话题了。一个简单的做法是设一个动态阈值:
def filter_hits(hits: List[dict], drop_ratio: float = 0.15) -> List[dict]: """丢弃相似度明显低于最高分的那些结果""" if not hits: return [] max_score = max(h["score"] for h in hits) threshold = max_score - drop_ratio return [h for h in hits if h["score"] >= threshold]4.5 并发请求下 Ollama 响应变慢甚至拒绝连接
现象:多个用户同时查询时,embedding 请求排队,延迟从 1 秒飙到 10 秒以上,偶尔返回 503。
原因:Ollama 默认的并发处理能力有限,embedding 和生成模型共享同一个推理队列。如果生成模型正在处理长文本,embedding 请求会被阻塞。
解决:把 embedding 和生成模型拆到两个 Ollama 实例上,监听不同端口:
# 实例1:embedding 专用,监听 11434 OLLAMA_HOST=0.0.0.0:11434 ollama serve # 实例2:生成专用,监听 11435 OLLAMA_HOST=0.0.0.0:11435 ollama serve然后在代码里分别指向不同端口。如果机器资源有限跑不了两个实例,至少在应用层加一个请求队列,控制并发数不超过 2。
5. 进阶技巧:用混合检索把 BGE-M3 的稀疏能力也用上
BGE-M3 除了稠密向量,还能输出稀疏权重(类似 SPLADE 的 token 权重)和多向量表示。Ollama 的 embeddings 接口目前只返回稠密向量,但你可以用 transformers 直接加载 BGE-M3 拿到稀疏权重,做混合检索。混合检索的核心思路是:稠密向量负责语义匹配,稀疏权重负责关键词精确匹配,两者加权融合后排序。
具体做法是,用FlagEmbedding库加载 BGE-M3,分别获取 dense 和 sparse 表示:
from FlagEmbedding import BGEM3FlagModel model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True) # 获取稠密和稀疏两种表示 output = model.encode( ["什么是向量检索", "向量检索的原理"], return_dense=True, return_sparse=True, return_colbert_vecs=False ) dense_vecs = output['dense_vecs'] # 形状 (2, 1024) sparse_weights = output['lexical_weights'] # 列表,每个元素是 {token_id: weight}稀疏权重的用法是计算 query 和 doc 之间的 token 重叠加权得分。FlagEmbedding 提供了compute_lexical_matching_score方法直接算:
query_sparse = model.encode(["向量检索"], return_sparse=True)['lexical_weights'][0] doc_sparse = model.encode(["向量检索的原理"], return_sparse=True)['lexical_weights'][0] lex_score = model.compute_lexical_matching_score(query_sparse, doc_sparse) print(f"稀疏匹配得分: {lex_score:.4f}")最终排序分数 = α × 稠密相似度 + (1-α) × 稀疏匹配得分。α 一般设 0.7 左右,稠密为主、稀疏为辅。如果你的场景里关键词匹配很重要(比如产品型号、专有名词),可以把 α 降到 0.5。
这套混合检索的代价是需要额外加载一个 transformers 模型,显存占用增加约 2GB。如果机器跑不动,可以只在检索阶段用稀疏权重做粗排,稠密向量做精排,这样只需要在检索时加载 BGE-M3 的 tokenizer 和稀疏头,显存压力小很多。
验证混合检索是否有效的方法:准备 20 到 30 个查询,每个查询标注正确的文档块 id,分别用纯稠密和混合检索跑一遍,算 Recall@5 和 MRR。如果混合检索的 Recall@5 比纯稠密高 5 个百分点以上,就值得上。我自己的经验是,在专有名词密集的技术文档场景里,混合检索的召回提升通常在 8 到 15 个百分点,但纯语义问答场景提升不明显,甚至可能因为稀疏噪声导致轻微下降。所以上之前先做小规模评测,别盲目堆模块。
最后说一个我踩过的坑:BGE-M3 的稀疏权重里,不同 token 的权重范围差异很大,直接做点积会被高频 token 主导。FlagEmbedding 内部做了归一化,但如果你自己实现稀疏匹配,记得对权重做 log 平滑或者截断,否则效果还不如纯稠密。希望帮到你。
本文还有配套的精品资源,点击获取