Ollama RAG:本地私人知识库问答
本章节我们把前面学到的嵌入、检索、生成能力组合成一个真正可用的应用:把你的文档喂给本地模型,让它只根据你的资料回答问题,并注明出处。
完整代码不到 100 行,零外部向量库依赖,复制即可运行。
需求拆解与技术选型
先明确问题:大模型不知道你硬盘里的资料,直接问只会得到编造的答案。RAG(检索增强生成)的思路是"开卷考试"——先从资料里检索相关片段,把片段塞进提示词,让模型照着作答。
技术选型遵循"最小依赖"原则:
| 环节 | 选型 | 理由 |
|---|---|---|
| 嵌入模型 | embeddinggemma | 体积小、本地快;备选 qwen3-embedding |
| 生成模型 | qwen3.5:4b | 中文好、16GB 内存可跑 |
| 向量存储 | Python 列表 + 余弦相似度 | 零依赖,教学清晰;量大换 Chroma 等专业库 |
| 文档格式 | txt / md | 纯文本即可讲清原理,PDF 见文末扩展 |
需要拉取两个模型:
ollama pull embeddinggemma
ollama pull qwen3.5:4bEmbeddingGemma 是 Google 开源的轻量多语言文本嵌入(Embedding)模型,专门用于把文本转为向量,非常适合本地设备离线部署,Ollama 已经对该模型做适配封装,可以直接拉取使用。
第一步:文档解析与切片
模型上下文有限,整本文档塞不进去,需要切成小片段。切片策略用最朴素的"定长 + 重叠":每片 300 字,相邻片段重叠 50 字,避免关键句被切断。
实例
# 文件路径:rag.py
import os
def load_chunks(folder, size=300, overlap=50):
"""读取文件夹中所有 txt/md 文件,切成带来源标记的片段"""
chunks = []
for name in sorted(os.listdir(folder)):
if not name.endswith(('.txt', '.md')):
continue
path = os.path.join(folder, name)
text = open(path, encoding='utf-8').read().strip()
# 滑动窗口切片:步长 = size - overlap
step = size - overlap
for i in range(0, len(text), step):
piece = text[i:i + size].strip()
if len(piece) > 20: # 过滤太短的碎片
chunks.append({'file': name, 'text': piece})
return chunks第二步:向量化与检索
把所有片段和用户问题都转成向量,用余弦相似度找出与问题最相关的 top-k 片段。
实例
import ollama
EMBED_MODEL = 'embeddinggemma'
def embed(texts):
"""批量生成向量(输入列表,输出向量列表)"""
result = ollama.embed(model=EMBED_MODEL, input=texts)
return result['embeddings']
def cosine(a, b):
"""余弦相似度:越接近 1 越相似"""
dot = sum(x * y for x, y in zip(a, b))
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
return dot / (na * nb)
def search(question, chunks, vectors, top_k=3):
"""检索:返回最相关的 k 个片段及其相似度"""
q_vec = embed([question])[0]
scored = [
(cosine(q_vec, v), c)
for v, c in zip(vectors, chunks)
]
scored.sort(key=lambda x: -x[0])
return scored[:top_k]embeddinggemma 输出的是 L2 归一化向量,此处仍保留完整余弦公式,让原理一目了然;换成点积结果相同。
第三步:组装提示词并生成带引用的回答
把检索到的片段连同来源编号一起写进提示词,明确要求模型"仅依据资料回答"并标注引用。
实例
from ollama import chat
GEN_MODEL = 'qwen3.5:4b'
def answer(question, chunks, vectors):
hits = search(question, chunks, vectors, top_k=3)
# 把片段与来源编号拼进上下文
context = '\n\n'.join(
f'[资料{idx}] (来自 {c["file"]})\n{c["text"]}'
for idx, (score, c) in enumerate(hits, 1)
)
prompt = f'''仅根据以下资料回答问题,并标注使用的资料编号。
如果资料不足以回答,请直接说明不知道。
资料:
{context}
问题:{question}'''
response = chat(
model=GEN_MODEL,
messages=[{'role': 'user', 'content': prompt}],
options={'temperature': 0.2}, # 低温度,减少发挥
)
print(response.message.content)
print('\n参考来源:')
for idx, (score, c) in enumerate(hits, 1):
print(f' [资料{idx}] {c["file"]}(相似度 {score:.3f})')完整运行:主程序入口
实例
# 追加到 rag.py 末尾
if __name__ == '__main__':
folder = 'docs' # 把你的 txt/md 文档放进 docs 文件夹
chunks = load_chunks(folder)
# 离线索引:一次性向量化全部片段
print(f'正在索引 {len(chunks)} 个片段...')
vectors = embed([c['text'] for c in chunks])
print('索引完成。\n')
# 在线问答循环
while True:
q = input('问题(exit 退出):').strip()
if q.lower() == 'exit':
break
if q:
answer(q, chunks, vectors)
print()准备资料并运行:
mkdir docs
cp RUNOOB-python-notes.md docs/
python rag.py输出:
$ python rag.py
正在索引 42 个片段...
索引完成。
问题(exit 退出):Python 的切片是什么?
Python 切片是用 [start:stop:step] 从序列截取子序列的语法 [资料1]。
例如 s[1:4] 取索引 1 到 3 的字符 [资料2]。
参考来源:
[资料1] RUNOOB-python-notes.md(相似度 0.712)
[资料2] RUNOOB-python-notes.md(相似度 0.668)注意回答末尾的来源标注:每句话都能追溯到你 docs 目录里的具体文件,这正是 RAG 相比"直接问模型"的核心价值。
效果调优与常见问题
RAG 效果不好,八成出在检索环节而不是生成环节。调优杠杆按影响力排序:
| 杠杆 | 默认值 | 调整方向 |
|---|---|---|
| 切片大小 size | 300 字 | 答得太碎就调大;太大则检索不准,经验区间 200~500 |
| 重叠 overlap | 50 字 | 句子频繁被切断时调大 |
| 检索条数 top_k | 3 | 资料密集时加大,但会占用更多上下文 |
| 相似度阈值 | 未启用 | 过滤相似度过低的片段,减少不相关干扰 |
| 生成温度 | 0.2 | 保持低温,知识库不需要创造性 |
高频问题速查:
| 现象 | 原因 | 对策 |
|---|---|---|
| 回答与资料无关 | 检索没命中 | 检查切片是否太小/太大,确认问题语言与资料一致 |
| 模型仍编造答案 | 提示词约束不足 | 强化"仅根据资料"表述,检索不到时直接回答"不知道" |
| 索引特别慢 | 片段过多逐条嵌入 | 使用批量 embed;文档量大时改用专业向量库与增量索引 |
| 长文档回答变差 | 关键信息被切碎 | 按标题/段落等语义边界切片代替纯定长 |
生产化路线图:文档量上万或需要 PDF 解析时,把"列表向量库"替换为 Chroma 一类专业向量库(提供持久化与高效近邻检索),用 pypdf 抽取 PDF 文本,其余代码骨架不变——这正是本篇把各环节解耦的意义。
AI 思考中...