“我进不了账户了”和“怎样重置密码”没有几个相同的字,却可能在找同一条帮助文档。关键词搜索漏掉这种表达时,可以试试向量检索。但即使四条 FAQ 都不相关,排序程序也照样能给出第一名。这是搭本地知识库时很容易忽略的边界。
这次不做聊天机器人,而是用 Ollama、一个文本嵌入模型和 Python,做出能显示编号、分数、原文的检索小样。适合维护帮助中心、内部操作说明和售前资料的运营或开发同学。先用虚构数据跑通,不把客户记录带进练习;也不需要向量数据库、付费 API 或生成答案的聊天模型。
用四条短 FAQ 把“找到”和“答对”分开
示例包含 F01 重置密码、F02 修改账单地址、F03 导出任务、F04 关闭工作区。这些规则都是教学设定,不是任何产品的真实服务条款。每条只讲一个问题,保留固定编号和标题,便于人工检查;不要把四条拼成一个长段落,否则命中后仍不知道是哪条规则起作用。
准备三类问题:同义改写“我进不了账户了,密码不记得”;明确目标“任务列表怎样下载成 CSV”;库外问题“你们能修打印机吗”。前两类有供人工核对的目标 F01、F03,第三类没有答案。这里的目标是测试标签,不是承诺模型一定会排出这个结果。后面要记录的是实际前两名,而不是只挑一次成功的截图。
环境选文本模型,别把新旧版本混在一起
从 Ollama 官网下载适合系统的安装包,启动应用后,在 Windows PowerShell 检查命令并拉取模型。另需已安装的 Python 3.10 或更新版本;下方代码只用标准库,不运行 pip 安装。若终端找不到命令,安装后重新打开终端,再检查安装路径,不要随意复制网上的提权脚本。
ollama --version
ollama pull embeddinggemma:300m
ollama list
py -3 --version
本文固定用 embeddinggemma:300m 这一代文本模型;官方标签页标注约 622 MB、2K 上下文,要求 Ollama 0.11.10 或更新版本。下载大小不等于运行内存占用,也不能据此保证每台电脑的速度。它不是 Google 后来的 EmbeddingGemma 2:不要把二代的多模态能力、上下文和授权说明套到这个标签上。模型适用 Gemma 条款,使用前另行核对,不因运行器开源就推定权重也是 MIT。
本例只调用本机 127.0.0.1:11434,模型拉取需要联网;成功下载后,这段检索代码不调用云端模型。保留默认回环监听,不为了跑练习把接口改成 0.0.0.0 或挂公网隧道。若要求禁用 Ollama 的云功能,可按官方 FAQ配置本地模式并重启应用;这与“整个电脑从此不会联网”是两回事。
保存脚本,一次得到问题和文档的向量
新建独立练习文件夹,将下面内容保存为 UTF-8 的 faq_search.py。脚本只向固定本机地址发送这四条样本和输入问题,输出到终端,不改业务资料。它显式关闭环境代理并拒绝 HTTP 重定向,避免练习文本意外走向其他地址;这不是对整台机器网络安全的审计。
查询使用 task: search result | query: 前缀,文档使用 title: 标题 | text: 原文,遵循原模型卡的检索格式。两侧采用同一模型、同一批请求。truncate 设为 False:超出上下文就报错,不默默只取开头;2048 token 也不是 2048 个汉字。这里不手动指定维度,并检查返回数量、维度和数值,防止把异常向量继续参与排序。
import json
import math
import sys
import urllib.error
import urllib.request
MODEL = "embeddinggemma:300m"
URL = "http://127.0.0.1:11434/api/embed"
DOCS = [
[
"F01",
"重置密码",
"登录页选择忘记密码,通过已验证邮箱接收重置链接。收不到邮件时检查垃圾箱,再联系管理员。"
],
[
"F02",
"修改账单地址",
"工作区管理员可在账单设置中修改下一期账单地址;已生成的账单请联系支持。"
],
[
"F03",
"导出任务",
"工作区管理员可在任务页导出 CSV。导出文件只含当前工作区内有权访问的任务。"
],
[
"F04",
"关闭工作区",
"只有所有者能申请关闭工作区。关闭前先导出需要保留的任务,并与成员确认。"
]
]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def embed(texts):
data = json.dumps({
"model": MODEL, "input": texts, "truncate": False
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
URL, data=data, headers={"Content-Type": "application/json"},
method="POST"
)
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}), NoRedirect()
)
with opener.open(req, timeout=120) as response:
vectors = json.load(response)["embeddings"]
if not isinstance(vectors, list) or len(vectors) != len(texts):
raise ValueError("Embedding count mismatch")
dimension = None
normalized = []
for vector in vectors:
if not isinstance(vector, list) or not vector:
raise ValueError("Empty or invalid vector")
if dimension is None:
dimension = len(vector)
if len(vector) != dimension:
raise ValueError("Embedding dimension mismatch")
if any(type(x) not in (int, float) or not math.isfinite(x)
for x in vector):
raise ValueError("Invalid vector number")
norm = math.sqrt(sum(x * x for x in vector))
if not math.isfinite(norm) or norm == 0:
raise ValueError("Invalid vector norm")
normalized.append([x / norm for x in vector])
return normalized
def search(question):
if not question.strip():
raise ValueError("Question must not be empty")
texts = ["task: search result | query: " + question]
texts += [f"title: {title} | text: {text}"
for _, title, text in DOCS]
vectors = embed(texts)
query, documents = vectors[0], vectors[1:]
ranked = [
(sum(a * b for a, b in zip(query, vector)), doc)
for vector, doc in zip(documents, DOCS)
]
return sorted(ranked, key=lambda item: (-item[0], item[1][0]))[:2]
if __name__ == "__main__":
try:
if len(sys.argv) != 2:
raise ValueError('Usage: python faq_search.py "question"')
for score, (doc_id, title, text) in search(sys.argv[1]):
print(f"{doc_id}\t{score:.4f}\t{title}\n{text}\n")
except (ValueError, KeyError, TypeError,
urllib.error.URLError, TimeoutError) as exc:
print("Search failed: check local service, model and inputs ("
+ type(exc).__name__ + ")", file=sys.stderr)
raise SystemExit(1)
在脚本所在目录运行下面的命令。首次加载可能比后续慢;若本机模型服务尚未启动,先从应用启动,不要因为连接失败就把 URL 换成陌生的在线“免费接口”。
py -3 .\faq_search.py "我进不了账户了,密码不记得"
py -3 .\faq_search.py "任务列表怎样下载成 CSV"
py -3 .\faq_search.py "你们能修打印机吗"
两条候选不是两条可直接发送的回答
每条输出的顺序是 FAQ 编号、四位小数的余弦相似度、标题和原文。脚本会归一化向量,再用点积求余弦相似度。不要把 0.8 改写成“80% 正确率”,也不要从别人的演示照抄一个通用及格线:模型、语料和问题改变后,分数含义会变。
尤其要看打印机问题。只要四条样本成功返回向量,程序就会列出两条候选,因为它只负责排序,没有拒答器。若要接到业务问答,必须另做库外问题测试、阈值校准和人工回退;本篇到“展示候选供人核对”为止。对导出问题,还要读原文中的管理员及权限限制,不能只凭标题承诺人人都能导出。
本文做了脚本语法与模拟响应检查,验证请求格式、排序和异常处理,没有下载模型或执行真实嵌入推理。因此不提供冒充实测的分数、耗时或中文命中率。你运行后可用“问题、人工目标、实际前两名、是否可用、原因”五列记录结果,这份记录比孤立的最高分更有价值。
让 AI 帮你出测试题,而不是扩写服务政策
可以用站内介绍的 Claude 为这四条虚构 FAQ 生成测试问题。它只辅助设计评估,不参与本机检索。若换成内部资料,需先取得外发授权并脱敏;本地脚本不上传,不代表你另开一个云端对话粘贴内容也仍然本地。
下面是四条虚构 FAQ。请生成 8 个检索测试问题:4 个同义改写、2 个相近但限制条件不同的问题、2 个资料中没有答案的问题。输出问题、预期 FAQ 编号或“无答案”、人工判断依据。不要新增功能、服务承诺或办理时限,不编造相似度。编号只能引用 F01–F04;有歧义时写明歧义。
把 AI 给的标签逐条人工复核。例如“普通成员能导出所有工作区任务吗”不能标成“F03 支持这个操作”;F03 恰好包含反例限制。检索到相关材料,与材料支持用户想做的事,是两个判断。测试题还应保留完全无关的问题,避免只测资料里本来就有的关键词。
跑不通时,按现象缩小范围
- 连接失败:确认 Ollama 正在运行且仍监听本机端口;已有后台服务时不要再启动第二个服务抢端口。
- 模型不存在或加载失败:检查 ollama list 的完整标签、下载是否完成和本机资源;不要换一个聊天模型冒充嵌入模型。
- 长文本报错:这是关闭截断后的预期保护。按独立问题拆段,并保留来源编号,不是随意删掉尾部限制。
- 维度不一致或数值异常:脚本会停止。不要补零、删维度后硬算;重新确认调用和模型版本。
- 分数高但内容不支持:回到原文和测试标签,补反例;相关性分数不能授权操作。
这份小样每次都重算四条文档,方便观察,不是大规模索引架构。正式扩展时再考虑缓存:记录模型完整标识、文档版本、前缀和向量维度;文档修改或模型更换后重建相应索引,不能把不同版本的向量直接混着比。向量也应按业务数据保护,不要当成绝对不可还原的匿名资料。
交付前检查五件事:四条样本能追到原文;同义问题有记录;库外问题被标成无依据;终端分数未包装成正确率;没有自动给用户发答案。完成这几项,再决定是否接入生成模型。对于四条 FAQ,先把检索边界看清楚,比先搭一套复杂 RAG 系统更有用。