省流:如果你只打算用远端嵌入模型(比如 OpenAI 的 text-embedding-3-small),那 Cherry Studio 开箱即用,本文可以直接划走。

本文写给那些执意要把嵌入模型也跑在本地的人。我踩了两轮坑,试了三套方案,最后用一个 Python 脚本收了尾。全程记录,供后来者避雷。


一、背景:我到底想干什么

需求很简单:我想在 Cherry Studio 里建一个知识库,把项目文档、API 接口说明这些东西喂进去,做本地 RAG 检索。

聊天模型我已经在本地跑起来了,问题卡在文本嵌入模型(Embedding Model)这一环。我不想让文档内容走网络出去,所以坚持要用本地嵌入。

于是就有了下面这段血泪史。


二、第一轮失败:Ollama + bge-m3

这是最"标准"的方案,网上教程一抓一大把。

2.1 部署过程

先在 Ollama 里拉模型:

ollama pull bge-m3

确认模型在位:

ollama list

在这里插入图片描述

然后在 Cherry Studio 里配置 Ollama 服务:

设置 → 模型服务 → Ollama
API 地址:http://localhost:11434
添加嵌入模型:bge-m3

在这里插入图片描述

2.2 翻车现场

在知识库里上传文本,点击嵌入,界面一直没有响应,然后弹出报错:

Error invoking remote method 'knowledge-base:search':
TypeError: Cannot read properties of undefined (reading '0')

在这里插入图片描述

2.3 排查:到底是谁的锅

一开始我怀疑是 Ollama 版本太老(早期版本 ollama embed 命令都没有)。查了一下版本:

ollama --version
# 0.30.11

版本很新,没问题。那就直接测接口。

测旧接口 /api/embeddings

Invoke-RestMethod -Uri "http://localhost:11434/api/embeddings" `
  -Method Post `
  -ContentType "application/json" `
  -Body (@{
    model = "bge-m3"
    prompt = "测试文本"
  } | ConvertTo-Json)

返回了正常的向量数组。

测新接口 /api/embed

Invoke-RestMethod -Uri "http://localhost:11434/api/embed" `
  -Method Post `
  -ContentType "application/json" `
  -Body (@{
    model = "bge-m3"
    input = "测试文本"
  } | ConvertTo-Json)

也正常返回 embeddings 字段。

在这里插入图片描述

关键对照实验:我把嵌入模型换成远端的 text-embedding-3-small,知识库一切正常。

结论浮出水面:

  • ✅ Ollama 本身正常
  • ✅ bge-m3 正常
  • ✅ 两个嵌入接口都能正确返回
  • ✅ 远端 OpenAI 嵌入在 Cherry Studio 里正常
  • ❌ 唯独 Cherry Studio 调本地 Ollama 嵌入就崩

2.4 根因:返回格式对不上

Cannot read properties of undefined (reading '0') 这个错误本质是:Cherry Studio 拿到返回体后,按某个路径去取 [0],结果那个路径是 undefined

对比三种返回格式就明白了:

OpenAI / text-embedding-3-small 的格式:

{
  "data": [
    { "embedding": [0.1, 0.2, ...], "index": 0 }
  ]
}

Ollama /api/embeddings

{ "embedding": [0.1, 0.2, ...] }

Ollama /api/embed

{ "embeddings": [[0.1, 0.2, ...]] }

Cherry Studio 按 OpenAI 的 data[0].embedding 去解析,而 Ollama 原生接口根本没有 data 字段,取 data[0] 自然就是 undefined,再取 [0] 直接抛异常。

我换了 nomic-embed-text,一样的错误——这就坐实了:跟具体模型无关,是 Cherry Studio 对 Ollama 原生嵌入返回格式的解析问题。


三、第二轮失败:LM Studio

既然怀疑是 Ollama 接口格式问题,那我换个能提供 OpenAI 兼容接口的后端总行了吧。于是我上了 LM Studio。

3.1 部署过程

  1. LM Studio 里下载一个文本嵌入模型
  2. 配置好之后,开启本地服务(Local Server)
  3. LM Studio 默认就暴露 OpenAI 兼容接口,地址类似 http://localhost:1234/v1

然后在 Cherry Studio 里以 OpenAI 类型接入:

API 地址:http://localhost:1234/v1
API Key:随便填
嵌入模型:填 LM Studio 里的模型名

3.2 翻车现场

这次没有报格式错误,但更糟心——Cherry Studio 的知识库那边一直显示 “等待中”,模型压根没连通的样子。

在这里插入图片描述

排查了半天连接、端口、模型名,始终没能让它跑起来。考虑到时间成本,我放弃了 LM Studio 这条路。

顺便说一句,我在 Cherry Studio 里也试过把 Ollama 走 OpenAI 兼容接口 http://localhost:11434/v1,同样没成。看接口预览时我注意到一个细节:聊天走的是 /api/chat,切成 OpenAI 后变成 /v1/chat/completions——这俩都是对话接口,嵌入接口是另一套,配置时千万别把嵌入模型误加到聊天模型列表里。


四、转机:AnythingLLM 跑通了

两轮失败后,我换了个思路:不直接让 Cherry Studio 连后端,而是中间加一层。

我装了 AnythingLLM,配置嵌入模型时选 Ollama,本质上底层还是 Ollama 在跑 bge-m3,但 AnythingLLM 在客户端做了一层 API 中转,对外暴露标准的 OpenAI 兼容接口。

4.1 配置过程

  1. 提前打开ollama,然后打开 AnythingLLM,进初始化向导
  2. 嵌入后端选 Ollama,地址 http://localhost:11434,模型选 bge-m3
  3. 向量数据库用内置的 LanceDB 即可

在这里插入图片描述

然后在 AnythingLLM 里生成 API Key,把它作为 OpenAI 服务接入 Cherry Studio:

API 地址:http://localhost:3001/api/v1/openai
API Key:AnythingLLM 生成的 Key

在这里插入图片描述

4.2 通了

这次知识库嵌入和检索都正常了。

为什么 AnythingLLM 能通,直连 Ollama 不行?

因为 AnythingLLM 做的那层中转,把 Ollama 原生的 { "embedding": [...] } 重新包装成了 OpenAI 标准的 { "data": [{ "embedding": [...] }] }。Cherry Studio 拿到的就是它认识的格式,data[0].embedding 能正确取到,问题自然消失。

这也反过来验证了第二章的根因判断:核心矛盾就是返回格式。


五、最终方案:一个 Python 脚本搞定

AnythingLLM 虽然能用,但它是个完整的桌面应用,为了一个"格式中转"的功能装这么重的东西,我觉得不划算。而且我本地已经有 Ollama,再叠一层 AnythingLLM,软件太多,维护心智负担重。

于是我把不需要的软件都卸了,只保留一个思路:

既然问题只是"返回格式要符合 OpenAI 规范",那我自己写个几十行的服务,直接提供一个 OpenAI 兼容的 /v1/embeddings 接口不就行了?

5.1 核心思路

写一个 FastAPI 服务,做三件事:

  1. 本地加载 bge-m3 嵌入模型
  2. 对文本做 embedding + mean pooling + L2 归一化
  3. OpenAI 标准格式返回

我用的是 OpenVINO 的 IR 格式模型,可以在 GPU/CPU(甚至 NPU)上跑,不依赖 Ollama。核心是那个 /v1/embeddings 接口,返回结构严格对齐 OpenAI。

5.2 关键代码

服务的核心是嵌入接口,返回体的结构是能不能被 Cherry Studio 接受的关键:

@app.post("/v1/embeddings")
def embeddings(req: EmbeddingRequest):
    """OpenAI 兼容的 Embedding 接口,使用 OpenVINO IR 格式的 bge-m3"""
    if not embedding_loaded:
        raise HTTPException(status_code=503, detail="Embedding model not loaded")

    texts = [req.input] if isinstance(req.input, str) else req.input

    inputs = embedding_tokenizer(
        texts,
        padding=True,
        truncation=True,
        max_length=8192,
        return_tensors="np"
    )

    model_inputs = {
        "input_ids": inputs["input_ids"],
        "attention_mask": inputs["attention_mask"]
    }
    if "token_type_ids" in inputs:
        model_inputs["token_type_ids"] = inputs["token_type_ids"]

    outputs = embedding_model(model_inputs)
    last_hidden_state = outputs[embedding_model.output(0)]

    # mean pooling + L2 归一化
    embeddings = mean_pooling(last_hidden_state, inputs["attention_mask"])
    embeddings = normalize(embeddings)

    # 关键:按 OpenAI 标准格式组织返回体
    data = []
    for i, emb in enumerate(embeddings):
        data.append({
            "object": "embedding",
            "index": i,
            "embedding": emb.tolist()
        })

    total_tokens = int(np.sum(inputs["attention_mask"]))
    return {
        "object": "list",
        "data": data,          # <- 就是这个 data 数组,Cherry Studio 认它
        "model": req.model,
        "usage": {
            "prompt_tokens": total_tokens,
            "total_tokens": total_tokens
        }
    }

两个辅助函数:

def mean_pooling(last_hidden_state, attention_mask):
    """对 token 级 embedding 按 attention mask 做 mean pooling"""
    mask = np.expand_dims(attention_mask, axis=-1).astype(np.float32)
    sum_embeddings = np.sum(last_hidden_state * mask, axis=1)
    sum_mask = np.clip(mask.sum(axis=1), a_min=1e-9, a_max=None)
    return sum_embeddings / sum_mask

def normalize(embeddings):
    """L2 归一化"""
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    return embeddings / np.clip(norms, a_min=1e-9, a_max=None)

模型加载部分按 GPU → CPU 的顺序尝试(NPU 对 bge-m3 的动态 shape 支持不好,就不折腾了):

EMBEDDING_MODEL_PATH = "D:/ollama_models/bge-m3-ov"
EMBEDDING_DEVICES = ["GPU", "CPU"]

def load_embedding_model():
    global embedding_tokenizer, embedding_model, embedding_loaded, embedding_device
    embedding_tokenizer = AutoTokenizer.from_pretrained(EMBEDDING_MODEL_PATH)
    core = ov.Core()
    model_xml = os.path.join(EMBEDDING_MODEL_PATH, "openvino_model.xml")
    for dev in EMBEDDING_DEVICES:
        try:
            embedding_model = core.compile_model(model_xml, dev)
            embedding_device = dev
            logger.info(f"Embedding model loaded on {dev}")
            embedding_loaded = True
            return True
        except Exception as e:
            logger.warning(f"Embedding {dev} compile failed: {e}")
    return False

再补一个 /v1/models 接口,方便 Cherry Studio 探测可用模型:

@app.get("/v1/models")
def list_models():
    models = []
    if embedding_loaded:
        models.append({
            "id": "bge-m3-npu",
            "object": "model",
            "created": 0,
            "owned_by": "local-npu"
        })
    return {"object": "list", "data": models}

启动:

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

如果你没有 OpenVINO 环境,把加载和推理部分换成 sentence-transformerstransformers 直接跑 bge-m3 也一样,关键是返回体保持 OpenAI 格式

5.3 启动服务并自测

python npu_llm_server.py

看到日志里出现 Embedding model loaded on GPU(或 CPU)就说明模型加载成功。

在这里插入图片描述

先自己测一下接口,确认返回是 OpenAI 格式:

$ApiUri = "http://127.0.0.1:8000/v1/embeddings"
$ModelName = "bge-m3-npu"
$TestInput = "Hello World"

$body = @{ model = $ModelName; input = $TestInput } | ConvertTo-Json

try {
    $response = Invoke-RestMethod -Uri $ApiUri -Method Post -ContentType "application/json" -Body $body -TimeoutSec 30
    Write-Host "OK - Model: $($response.model) | Dim: $($response.data[0].embedding.Count) | Tokens: $($response.usage.total_tokens)"
    Write-Host "`nResponse:" -ForegroundColor Cyan
    $response | ConvertTo-Json -Depth 5
}
catch {
    Write-Host "FAILED: $($_.Exception.Message)" -ForegroundColor Red
}

返回里应该能看到 object: listdata 数组,每个元素带 embedding

在这里插入图片描述


六、在 Cherry Studio 里接入自建服务

  1. 打开 Cherry Studio,进入 设置 → 模型服务
  2. 添加一个 OpenAI 类型的服务
配置项
名称 Local-Embedding(随便取)
API 地址 http://127.0.0.1:8000/v1
API Key 随便填,比如 local
嵌入模型 bge-m3-npu
  1. 新建知识库,嵌入模型选 bge-m3-npu
  2. 上传文档,等待索引完成
  3. 提问检索

这次一切正常,嵌入和检索都跑通了,而且不依赖 Ollama、也不依赖 AnythingLLM,只有一个 Python 进程。
在这里插入图片描述


七、避坑清单

写给赶时间的人,这几条是本文的精华:

  1. Cannot read properties of undefined (reading '0'),几乎可以断定是 Cherry Studio 按 OpenAI 格式 data[0].embedding 解析,而 Ollama 原生接口没有 data 字段。跟你用哪个嵌入模型无关。
  2. 验证后端是否正常,用 PowerShell / curl 直接打接口,别只在 Cherry Studio 里瞎猜。Ollama 的 /api/embeddings/api/embed/v1/embeddings 三种返回格式各不相同。
  3. 远端 text-embedding-3-small 能通、本地不通,说明问题在"本地后端的返回格式",不在知识库功能本身。
  4. LM Studio 那条路我没走通(一直"等待中"),如果你要试,重点排查端口、模型名和服务是否真的监听成功。
  5. AnythingLLM 能救急,本质是它帮你把 Ollama 的返回包装成了 OpenAI 格式。但为一个格式转换装个桌面应用,重了点。
  6. 最干净的方案是自己写个 OpenAI 兼容的 /v1/embeddings,几十行 FastAPI 搞定。核心只有一句话:返回体必须是 { "object": "list", "data": [{ "embedding": [...], "index": 0 }] }

八、写在最后

这趟折腾下来,最大的体会是:遇到"客户端连不上本地模型"这类问题,先用命令行把每个后端接口单独打一遍,看返回结构,比在 GUI 里反复点重试高效太多。

Cherry Studio 对 Ollama 原生嵌入接口的兼容,希望官方后续能修(当前版本:v1.9.11)。但在那之前,如果你也执意要本地嵌入,一个几十行的 Python 中转服务就是最省心的答案。


关于本文脚本的适用范围(重要)

需要说明的是,本文分享的部分脚本是基于我当前的电脑环境构建的:

  • 系统:Windows 11 x64
  • CPU:Intel Ultra 7 155H(自带 NPU / Intel AI Boost)
  • 推理框架:OpenVINO,模型用的是 bge-m3 的 OpenVINO IR 格式

所以脚本里用到了 openvinoopenvino_genai,并且按 GPU → CPU 的顺序编译模型(NPU 对 bge-m3 的动态 shape 支持不好,我没走 NPU)。

如果你的机器没有 OpenVINO 环境,或者不想折腾 IR 格式模型,直接照抄这个脚本是跑不起来的。 你需要把模型加载和推理那部分改写成 sentence-transformers 版本,思路是:

  • 去掉 import openvinoAutoTokenizer + ov.Core() 那一整套加载逻辑,换成 SentenceTransformer("BAAI/bge-m3") 直接加载;
  • /v1/embeddings 里的 tokenizer + mean pooling + normalize 手动流程也可以省掉,sentence-transformersmodel.encode(texts, normalize_embeddings=True) 一步就把归一化后的向量给你了;
  • 唯一不能动的是返回体结构——不管底层怎么换,最后返回的 JSON 必须保持 OpenAI 格式:
{
  "object": "list",
  "data": [
    { "object": "embedding", "index": 0, "embedding": [0.1, 0.2, ...] }
  ],
  "model": "bge-m3",
  "usage": { "prompt_tokens": 5, "total_tokens": 5 }
}

只要这个格式对,Cherry Studio 那头就能正常解析。至于底层是 OpenVINO、sentence-transformers 还是别的什么,它并不关心。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐