系列导读:本系列共 6 篇,带你从零到一构建完整的 RAG + LangGraph + MCP 项目。

  • 第 1 篇:最小 RAG 实现,纯 numpy,无任何 AI 框架
  • 第 2 篇(本文):接入 Ollama 本地大模型,实现真实语义检索
  • 第 3 篇:接入 ChromaDB 持久化向量数据库
  • 第 4 篇:用 LangChain 重构 + 多轮对话
  • 第 5 篇:LangGraph 多步推理工作流
  • 第 6 篇:MCP 工具调用协议集成

一、上一篇的问题

第 1 篇用随机向量模拟 Embedding,导致检索结果完全错误——“年假有多少天"检索不到"年假政策”。

根本原因:随机向量没有语义,无法表达文字之间的相似关系。

本文只改动两个函数,其余代码完全不变:

组件第 1 篇第 2 篇
embed()随机向量(无语义)Ollama nomic-embed-text(真实语义)
generate()返回模板字符串Ollama qwen2.5:7b 真实生成
VectorStorenumpy 数组(内存)numpy 数组(内存,不变)

二、Ollama 环境准备

# 1. 安装 Ollama(macOS)
brew install ollama

# 2. 启动服务
ollama serve

# 3. 拉取模型
ollama pull nomic-embed-text   # embedding 模型,82MB,专门做向量化
ollama pull qwen2.5:7b         # 生成模型,4.7GB,中文效果好

# 4. 安装 Python 依赖
pip install ollama numpy

为什么选 nomic-embed-text?

  • 体积小(82MB),专门做 embedding,比通用模型快
  • 输出 768 维向量,语义质量好
  • 开源,本地运行,数据不出本机

三、替换点 1:真实 Embedding

import ollama
import numpy as np

EMBED_MODEL = "nomic-embed-text"

def embed(text: str) -> np.ndarray:
    """
    调用 Ollama embedding 模型,把文本变成语义向量。
    nomic-embed-text 输出 768 维向量。

    对比第 1 篇:
    - 第 1 篇:hash(text) → 随机数 → 无语义
    - 第 2 篇:text → 神经网络 → 768维语义向量
    """
    response = ollama.embeddings(model=EMBED_MODEL, prompt=text)
    vec = np.array(response["embedding"], dtype=np.float32)
    vec = vec / np.linalg.norm(vec)  # 归一化
    return vec

现在"年假"和"年假政策"的向量距离会很近,"年假"和"报销流程"的向量距离会很远。

语义向量的直觉

# 语义相似 → 向量方向相近 → 余弦相似度高
embed("年假有多少天") ≈ embed("年假政策:员工入职满一年...")
  相似度 ≈ 0.85

# 语义不同 → 向量方向不同 → 余弦相似度低
embed("年假有多少天") ≈ embed("报销流程:填写费用报销单...")
  相似度 ≈ 0.12

四、替换点 2:真实大模型生成

CHAT_MODEL = "qwen2.5:7b"

def generate(query: str, contexts: list) -> str:
    context_text = "\n".join(f"- {c}" for c in contexts)

    prompt = f"""你是一个企业内部知识库助手。请根据以下资料回答用户问题。
如果资料中没有相关信息,请说"知识库中没有找到相关信息"。

【参考资料】
{context_text}

【用户问题】
{query}

【回答】"""

    # 调用 Ollama,stream=True 实时打印,用户体验更好
    print("\n🤖 模型回答(流式输出):")
    full_response = ""
    for chunk in ollama.generate(model=CHAT_MODEL, prompt=prompt, stream=True):
        token = chunk["response"]
        print(token, end="", flush=True)  # 实时输出每个 token
        full_response += token
    print()  # 换行
    return full_response

流式输出(stream=True)的好处:模型边生成边输出,用户不用等待全部生成完再看到结果,体验更好。


五、完整代码

import numpy as np
import ollama

EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL  = "qwen2.5:7b"

DOCUMENTS = [
    "年假政策:员工入职满一年后享有15天年假,满三年后20天。",
    "病假政策:病假需提供医院证明,当天请假需电话通知直属上级。",
    "请假流程:登录OA系统,填写请假申请单,提前3个工作日提交。",
    "加班规定:工作日加班按1.5倍计算,周末加班按2倍计算。",
    "报销流程:填写费用报销单,附发票原件,提交财务部审核。",
]

def embed(text: str) -> np.ndarray:
    response = ollama.embeddings(model=EMBED_MODEL, prompt=text)
    vec = np.array(response["embedding"], dtype=np.float32)
    return vec / np.linalg.norm(vec)

class VectorStore:  # 和第 1 篇完全一样
    def __init__(self):
        self.texts = []
        self.vectors = []

    def add(self, text: str):
        self.texts.append(text)
        self.vectors.append(embed(text))
        print(f"  ✓ 已向量化:{text[:20]}...")

    def search(self, query: str, top_k: int = 3) -> list:
        query_vec = embed(query)
        matrix = np.array(self.vectors)
        scores = matrix @ query_vec
        top_indices = np.argsort(scores)[::-1][:top_k]
        return [(self.texts[i], float(scores[i])) for i in top_indices]

def generate(query: str, contexts: list) -> str:
    context_text = "\n".join(f"- {c}" for c in contexts)
    prompt = f"""你是一个企业内部知识库助手。请根据以下资料回答用户问题。
如果资料中没有相关信息,请说"知识库中没有找到相关信息"。

【参考资料】
{context_text}

【用户问题】
{query}

【回答】"""
    full_response = ""
    for chunk in ollama.generate(model=CHAT_MODEL, prompt=prompt, stream=True):
        token = chunk["response"]
        print(token, end="", flush=True)
        full_response += token
    print()
    return full_response

class SimpleRAG:
    def __init__(self):
        self.store = VectorStore()

    def build_index(self, documents: list):
        print(f"📚 建立索引,共 {len(documents)} 个文档片段...")
        for doc in documents:
            self.store.add(doc)
        print("✅ 索引建立完成\n")

    def query(self, question: str, top_k: int = 2) -> str:
        print(f"\n🔍 问题:{question}")
        results = self.store.search(question, top_k=top_k)
        print(f"\n📋 检索到 {top_k} 个相关片段:")
        for i, (text, score) in enumerate(results, 1):
            print(f"  [{i}] 相似度={score:.3f}  {text}")
        contexts = [text for text, _ in results]
        return generate(question, contexts)

def check_models():
    try:
        models = ollama.list()
        available = [m.model for m in models.models]
        print(f"✅ Ollama 运行正常,可用模型:{available}")
        return True
    except Exception as e:
        print(f"❌ Ollama 未运行:{e}\n请先执行:ollama serve")
        return False

if __name__ == "__main__":
    if not check_models():
        exit()
    rag = SimpleRAG()
    rag.build_index(DOCUMENTS)
    rag.query("我想请假,怎么申请?")
    rag.query("年假有多少天?")
    rag.query("周末加班怎么算工资?")

运行:

ollama serve          # 终端1
python3 step2_ollama_rag.py  # 终端2

六、对比第 1 篇的变化

第 1 篇的问题:"年假有多少天?“检索到的是"绩效考核”(错误)。

第 2 篇的效果:"年假有多少天?“正确检索到"年假政策:员工入职满一年后享有15天年假…”(相似度 0.85+)。

只改了 embed() 和 generate() 两个函数,检索质量天壤之别。 这说明一件事:Embedding 模型的质量决定了 RAG 的上限。


七、第 2 篇的局限

目前还有一个问题:每次程序重启都要重新 embed。

8 个文档 × 768 维 = 6144 次浮点运算,虽然很快,但如果是几万个文档,每次启动都要花几分钟重新 embed,无法接受。

解决方案:向量持久化。这正是第 3 篇 ChromaDB 要解决的问题。


八、常用 Embedding 模型对比

模型维度大小中文效果适用场景
nomic-embed-text76882MB一般快速原型
bge-m310241.2GB优秀中文生产环境
text-embedding-ada-0021536云API优秀OpenAI 生态
mxbai-embed-large1024670MB良好多语言

总结

本文只改动了两个函数,但效果质的飞跃:

  • embed():随机向量 → Ollama 语义向量,检索准确率大幅提升
  • generate():模板字符串 → 真实大模型,回答质量质的提升

这验证了第 1 篇的设计思想:好的代码分层,让替换组件变得极其简单。

下一篇引入 ChromaDB,把向量持久化到磁盘,同时增加文档来源追踪(metadata)。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐