把公司内部的技术文档、个人笔记或者客户资料扔到云端做 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),拉完再关——模型文件存本地,之后永远离线可用。

把文档嚼碎了喂给向量库: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,再喂给大模型生成答案——那才是语义搜索的灵魂。但那是下一章的事了。

搭建本地语义搜索引擎
向量存好了,现在得让它们“开口说话”。用户输个“季度营收报告”,系统得从上万 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 年的技术笔记、二十多本电子书、外加公司内部那堆规范文档全塞进去之后,本地就能直接搜了。再也不用对着文件夹一个个翻,翻到眼冒金星。数据在自己硬盘上躺着,模型在自己显卡上跑着,谁也动不了。踏实得很。





评论