Ollama 本地语义检索:四条 FAQ 如何返回原文,而不急着生成答案

用 EmbeddingGemma 与 Python 做一个只返回候选原文的本地 FAQ 检索小样,检查查询格式、余弦分数、库外问题和超长文本,不把排序误当正确答案。

“我进不了账户了”和“怎样重置密码”没有几个相同的字,却可能在找同一条帮助文档。关键词搜索漏掉这种表达时,可以试试向量检索。但即使四条 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 系统更有用。

本文由 AI Islands 根据产品官网及公开资料独立整理。工具功能和价格可能变化,请以官网最新信息为准。