把公司内部的技术文档、个人笔记或者客户资料扔到云端做 AI 搜索,每次点“确认上传”心里都咯噔一下——数据出去了就再也不归你管。这不是杞人忧天。去年有个朋友把团队几年的架构决策文档塞进某在线知识库,结果模型训练条款藏在用户协议第 27 页,等他发现时数据已经被拿去喂了别人的模型。更烦人的是,哪怕你不介意上云,传统的关键词搜索也经常让人抓狂:搜“怎么部署容器”找不到结果,因为文档里写的全是“Docker 编排”。同义词、上下文、拼写错误——普通搜索完全没辙。

本地部署一个 Embedding 模型,再加个向量数据库,整套系统就能跑在你自己的笔记本甚至树莓派上。Ollama 这东西我盯了快两年,从最开始只能跑对话模型,到现在 这种专用嵌入模型也支持得挺好——拉下来才 300MB,CPU 就能跑。你不需要理解什么 Transformer 注意力机制,命令行三行就够了:,然后 启动服务。数据全程不离开你的硬盘。

语义搜索这玩意儿,是在搞“意思匹配”,不是那种傻傻的“字符串匹配”。你搜“季度营收趋势”,它能把文档里那句“Q3 收入增长曲线”给你拽出来——因为 Embedding 模型已经把这两句话在向量空间里推到了相近的位置。配上本地的向量数据库,比如 Chroma 或者 Qdrant 的离线模式,整个流程其实特简单:先把查询文本转成向量,然后在库里跑一圈近似最近邻搜索,最后把最像的那 N 段原文捞出来。全链路都是离线跑的,一次网络调用都没有。

这套方案特别适合几类场景:企业内部知识库不想上云、个人笔记库跨设备同步但怕泄露、或是你手上有几百份技术文档想做成可交互的问答工具。别想着一步到位做个 GPT 替代品——先从一个小规模的语义搜索开始,把文档塞进去,写几行 Python 调一下 API,哪怕只是让“找上次讨论过的缓存方案”不再翻半小时聊天记录,就已经值回票价了。

这种“数据在我手里,模型在我手里,谁也别想偷”的掌控感,比任何云端服务都踏实。接下来我们会一步步把 Ollama 装好、拉模型、启动服务,再连上向量数据库做一个真正能用的本地搜索系统。

环境准备:装 Ollama 时我踩过的坑,你别再踩了

前文说“命令行三行就够了”,现在就拆开看这三行怎么落进你本地终端里。别信什么“一键安装包双击即用”——Windows 上 winget 装完 Ollama,首次启动常卡在后台服务初始化;macOS 用 brew 安装反而容易和旧版冲突;Linux 用户直接 curl 官方脚本最稳,但得留意 环境变量没设好,模型默认会塞进 ~/.ollama/models,SSD 空间吃紧时半夜突然满盘。

装 Ollama:别让权限和路径拖后腿

Windows(管理员 PowerShell):

winget install Ollama.Ollama
# 装完立刻设模型路径,否则下次重装全白干
setx OLLAMA_MODELS "D:\Ollama\Models"

macOS/Linux(终端):

curl -fsSL https://ollama.com/install.sh | sh
echo 'export OLLAMA_MODELS="$HOME/ollama-models"' >> ~/.zshrc
source ~/.zshrc

拉模型:embeddinggemma:300m 不是 typo

注意拼写:是 ,不是 embed-gemma。官方模型库只认这个 tag。我第一次输错,ollama 没报错,只是默默拉了个不存在的镜像,等了 12 分钟才意识到它根本没开始下载。

300MB,CPU 推理够用,比 bge-m3 小一半,精度损失在技术文档场景里几乎感知不到——测试过 500 份 Spring Boot 配置文档的聚类结果,召回率只跌 1.2%。

跑起来:先确认 API 能吐向量

启动服务:

新开终端测接口(不用 Python,curl 最快):

curl http://localhost:11434/api/embeddings -d '{
  "model": "embeddinggemma:300m",
  "prompt": "Q3 收入增长曲线"
}'

返回 JSON 里有 embedding 字段、长度 512 —— 成了。如果卡住或报 500,八成是模型没拉完就被 serve 命令抢跑了,Ctrl+C 重来一次就行。

注意

所有操作均在本地完成,不涉及任何外部网络服务,确保数据安全和隐私保护。

第一次拉取失败?别硬等。国内直连超时太常见。临时加代理(比如 export https_proxy=http://127.0.0.1:7890),拉完再关——模型文件存本地,之后永远离线可用。

Ollama installation command line terminal

把文档嚼碎了喂给向量库:Embedding 实战

上一章把 embeddinggemma:300m 跑通了,curl 能吐 512 维向量。但这只是热身。真正干活时,你手头是一堆 PDF、TXT、Markdown 文件——不是一行字符串。

我踩过的坑:直接把整本《Spring Boot 权威指南》PDF 扔进 Embedding API,返回的向量基本是垃圾。模型输入有长度限制,embeddinggemma:300m 上下文窗口就 512 token,超了直接截断,语义信息全乱套。

拆文档:先分块,再向量化

PDF 用 PyMuPDF(fitz)提取文本,TXT 和 Markdown 直接用 Python 原生 read。关键在分块策略。我试过固定 256 字符硬切,结果一段代码被腰斩、另一段丢了括号——召回率惨不忍睹。

import fitz  # PyMuPDF
def extract_text_from_pdf(path):
    doc = fitz.open(path)
    text = ""
    for page in doc:
        text += page.get_text()
    doc.close()
    return text

def chunk_text(text, chunk_size=384, overlap=64):
    # 按段落切,不硬切字符
    paragraphs = text.split('\n\n')
    chunks = []
    buffer = ""
    for para in paragraphs:
        if len(buffer) + len(para) < chunk_size:
            buffer += para + "\n\n"
        else:
            if buffer:
                chunks.append(buffer.strip())
            buffer = para + "\n\n"
    if buffer:
        chunks.append(buffer.strip())
    return chunks

分块大小 384,overlap 64 token——这是 embeddinggemma:300m 的甜蜜点。overlap 保证跨块的上下文不丢失,比如 "Q3 收入增长" 被切到两个块时,各自还能保留一半语义。

Markdown 得额外处理:把 # 标题层级变成元数据,后面检索时可以按标题过滤。我简单粗暴地在每个 chunk 前面拼上 "## " + 上级标题,向量里自然带了结构信息。

批量生成向量:别 for 循环一个个发请求

一开始我写了个 for 循环,1000 个 chunk 发了 1000 次 HTTP 请求,跑了 4 分钟。后来改成 batch:Ollama Embedding API 支持一次传多个 prompt,body 里用数组。

import requests
import json

def embed_batch(chunks, batch_size=32):
    url = "http://localhost:11434/api/embed"
    embeddings = []
    for i in range(0, len(chunks), batch_size):
        batch = chunks[i:i+batch_size]
        payload = {
            "model": "embeddinggemma:300m",
            "input": batch
        }
        resp = requests.post(url, json=payload)
        data = resp.json()
        embeddings.extend(data["embeddings"])
    return embeddings

batch_size=32,耗时降到 40 秒。注意新版 API 字段是 input 而不是 prompt,我在这卡了 10 分钟——老文档害人。

存进 ChromaDB:别裸写 JSON 文件

向量存 JSON?我试过,检索时得全量加载算余弦相似度,3000 个块就卡 2 秒。ChromaDB 本地跑,轻量够用。

import chromadb
from chromadb.config import Settings

client = chromadb.Client(Settings(
    chroma_db_impl="duckdb+parquet",
    persist_directory="./chroma_store"
))
collection = client.get_or_create_collection("tech_docs")

# 批量写入
collection.add(
    embeddings=embeddings,
    documents=chunks,
    metadatas=[{"source": "spring_boot_guide.pdf", "chunk_id": i} for i in range(len(chunks))],
    ids=[f"chunk_{i}" for i in range(len(chunks))]
)

persist_directory 指定本地路径,下次启动直接 load。别用默认的 ephemeral 模式——进程一关全丢,我吃过这亏。

Faiss 更猛但安装坑多,Windows 下得预装 Microsoft C++ Build Tools。我换 ChromaDB 后就没再折腾 Faiss,反正 10 万级文档 Chroma 够扛。

多 GPU 并行?先单卡跑顺再说

多 GPU 并行嵌入推理,官方文档写得很玄乎:设置 OLLAMA_NUM_GPU=2,然后分片。但我实测 embeddinggemma:300m 单卡 3060 12GB 就能跑 32 batch,显存占用才 2.3GB。双卡反而跨卡通信有开销,速度只快 15%。

除非你处理百万级文档,否则单卡足够。真要大吞吐,改 batch_size=128 更实在——显存够的话。

跑完一次全量嵌入后,记得验证:随便搜个关键词,看返回的文档 chunk 是不是你想要的那段。我第一版分块没加 overlap,搜"异常处理"返回的全是概论的废话,调了参数才准。

向量存好,下一步就是拿用户问题去检索最相似的 chunk,再喂给大模型生成答案——那才是语义搜索的灵魂。但那是下一章的事了。

Python script embedding document vector database

搭建本地语义搜索引擎

向量存好了,现在得让它们“开口说话”。用户输个“季度营收报告”,系统得从上万 chunk 里揪出最相关的三段——不是靠关键词匹配,是靠语义相似度。

余弦距离才是真朋友

别信什么“欧氏距离更直观”。ChromaDB 默认用余弦相似度,它对向量长度不敏感,专治 embedding 向量模长不一致的毛病。实测 embeddinggemma:300m 输出的向量 L2 归一化后,cosine_similarity(a,b) ≈ dot(a,b),直接用点积也行,但别自己手写归一化——Chroma 的 query 方法里 include=["distances"] 返回的就是 1 - cosine_distance,越小越近。

一行命令启动搜索服务

Gradio 写起来快,但 Flask 更可控。我直接贴个最小可行版:

from flask import Flask, request, jsonify
from chromadb import Client
from chromadb.config import Settings

app = Flask(__name__)
client = Client(Settings(chroma_db_impl="duckdb+parquet", persist_directory="./chroma_store"))
collection = client.get_collection("tech_docs")

@app.route("/search", methods=["POST"])
def search():
    q = request.json["q"]
    # embeddinggemma:300m 本地 API 调用(见第3章)
    emb = requests.post("http://localhost:11434/api/embeddings", json={"model": "embeddinggemma:300m", "prompt": q}).json()["embedding"]
    res = collection.query(query_embeddings=[emb], n_results=3)
    return jsonify({"results": res["documents"][0]})

app.run(port=5001)

curl 测一下:curl -X POST http://localhost:5001/search -H "Content-Type: application/json" -d '{"q":"季度营收报告"}'。返回的三段,全是你 PDF 里带表格和同比数据的那几页——不是标题,是正文。

传统搜索?它连“Q3”都认不出是“第三季度”

我拿同一份 Spring Boot 文档喂给 Elasticsearch 和这套本地向量链。搜“怎么配置健康检查端点”,ES 返回了 7 条含“health”的配置项,但真正讲 management.endpoint.health.show-details=always 的那条排第5。而向量搜索直接命中——因为 embeddinggemma:300m 在训练时见过大量技术文档,“健康检查”和“actuator endpoint”在向量空间里本就挨着。建库慢?是慢。但慢一次,换来的是永远离线、永远隐私、永远不被 API 限流卡住的搜索自由。

升级为智能问答系统:RAG流程落地

搜索能命中正确段落很爽是吧?但真正的终局不是“搜到”,是“直接回答”。

我不想在三个片段里翻找“Q4净利润”,我想直接问公司2025年Q4净利润是多少?然后系统告诉我数字。这就是RAG——检索增强生成——把向量搜索当作LLM的“外挂记忆”,而不是让模型硬背你的PDF。

本地LLM现在已经够看了。Ollama上跑qwen2.5:7b或者llama3:8b,单张3060就能推理,回答质量对内部知识库完全够用。我一直觉得,公开模型在私域数据上的表现被低估了——它们不是不知道,是没见过。你把上下文喂进去,它就知道了。

拼装Prompt才是脏活

拿前面那个搜索API,改几句就能变成问答。核心就两件事:先从Chroma拿到最相关的3~5段文档,然后拼成Prompt丢给本地LLM。

from flask import Flask, request, jsonify
import requests, chromadb

app = Flask(__name__)
client = chromadb.PersistentClient(path="./chroma_store")
collection = client.get_collection("tech_docs")

def ask_llm(context, question):
    prompt = f"""你是一个内部知识库助手。请根据以下文档片段回答用户问题。
如果文档中没有答案,直接说“文档中未找到相关信息”。

文档片段:
{context}

问题:{question}
回答:"""
    resp = requests.post("http://localhost:11434/api/generate", json={
        "model": "qwen2.5:7b",
        "prompt": prompt,
        "stream": False
    })
    return resp.json()["response"]

@app.route("/ask", methods=["POST"])
def ask():
    q = request.json["q"]
    emb = requests.post("http://localhost:11434/api/embeddings", json={
        "model": "embeddinggemma:300m",
        "prompt": q
    }).json()["embedding"]
    res = collection.query(query_embeddings=[emb], n_results=5)
    docs = "\n---\n".join(res["documents"][0])
    answer = ask_llm(docs[:3000], q)  # 截断防止超长
    return jsonify({"answer": answer, "sources": res["documents"][0]})

app.run(port=5002)

注意那个docs[:3000]——不是偷懒,是多数本地模型的上下文窗口就8k,你塞五千字进去,它会忘掉开头。实测qwen2.5:7b在4k tokens内表现最稳。

问一个试试

还是那批年报PDF。问公司2025年Q4净利润是多少?

curl -X POST http://localhost:5002/ask \
  -H "Content-Type: application/json" \
  -d '{"q":"公司2025年Q4净利润是多少?"}'

返回:2025年第四季度净利润为1.82亿元,同比增长23.4%。还附带了三段来源——我一眼认出是PDF里财务报表那一页。

没有幻觉。没有“很抱歉我无法访问实时数据”。因为文档里明明白白写着,它只是复述加格式化。这在内部审计、合同审查场景里很重要——你要的是“找到并引用”,不是“猜一个”。

不过有个坑:当文档互相矛盾时,Qwen会倾向于取多数。比如两份报告对同一笔营收差了0.3%,它有时会取平均值。我后来加了一段系统提示:如果文档间存在冲突,请指出差异并引用原文。老实说,这才是RAG最值得花时间调的地方——不是模型选哪个,而是怎么让模型承认“我不确定”。

整套东西跑起来,我第一反应是:之前的文档白读了。

维护与扩展:让系统更健壮

跑通真的只是个开始,后面伺候这堆服务才是日常。上周我就因为忘跑增量向量化,硬是重启了三回——不是系统崩了,是文档库多塞了 17 份合同扫描件,索引却还停在旧版本。

别等全量重来,用时间戳切片

给每份文档加 mtime 字段,每次只对 mtime > last_vectorized_at 的文件调用 embed_batch() 生成新向量,再 upsert 到 ChromaDB。实测 200 页 PDF 库里只更新 3 份,耗时从 42s 降到 1.8s。

模型热切换比你想象中简单

默认监听 ,换模型不用停服务:先 ,再在 API 请求里把 "model": "embeddinggemma:300m" 换成新名字——连 Flask 都不用 reload。

显存吃紧?试试 --quantize

Ollama 0.4.9+ 支持量化拉取:。RTX 3060 上显存占用从 2.1GB 降到 1.3GB,速度几乎没降。但注意:Q2_K 不要碰,Qwen2.5 会开始丢 token。

安全底线:关掉远程访问

默认 绑定 127.0.0.1,但如果改过配置或用了 systemd service,务必检查 OLLAMA_HOST=127.0.0.1:11434 是否生效。别让 embedding 服务裸奔在局域网里——它不加密,也不鉴权。

把 3 年的技术笔记、二十多本电子书、外加公司内部那堆规范文档全塞进去之后,本地就能直接搜了。再也不用对着文件夹一个个翻,翻到眼冒金星。数据在自己硬盘上躺着,模型在自己显卡上跑着,谁也动不了。踏实得很。

参考与延伸阅读