ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

百炼平台文本向量技术详解:语义检索、去重与RAG实践

百炼平台文本向量技术详解:语义检索、去重与RAG实践

写文本向量这块,我前后折腾过不少方案,本地模型、在线API、开源仓库都试过。今天聊一聊在实际项目里用百炼平台通用文本向量(Embeddings)做语义检索、文本去重和知识库问答的经验。我会把从申请接入、参数理解、代码实现到问题排查整个过程都捋一遍,尽量把坑都提前指出来。

先说说这个东西到底解决什么问题。做搜索、做推荐、做内容审核,几乎都会碰上一个场景:判断两段文本"意思是否相近"。以前用关键词匹配,词换了个说法就废了;用编辑距离,句子一长就没谱。文本向量就是把这些文字变成一组数字坐标,语义上越像的句子,坐标越靠近。百炼通用文本向量就是一个已经训练好的服务,你传一段文字进去,它返回一串浮点数,直接拿来算相似度、做聚类或者喂给下游模型。它适合中小团队快速验证需求,也适合不想自己维护模型服务的场景。

需要说明的一点:本文不涉及具体账号开通和计费细节,所有流程以官方控制台实际显示为准。我主要分享我的技术方案设计和代码实现,你可以直接照着改。

1. 方案选型与整体设计

1.1 文本向量到底在解决什么问题

我接到一个新的内部工具需求:把公司散落在多个文档库里的产品说明、故障排查手册、历史工单内容统一做成一个语义检索入口。传统方案是分词加倒排索引,可用户问"机器开机没反应",文档里写的是"设备无法正常启动",字面完全没有交集,搜索就失效了。

文本向量的思路是让模型先把两边都转成向量,再去计算余弦相似度。这样"机器开机没反应"和"设备无法正常启动"在语义空间里会很接近,检索就能命中。本质上就是把一个文本匹配问题转成数学上的距离计算问题,把不确定性集中在模型训练阶段,推理阶段就只剩加减乘除。

除搜索以外,还有几个高频场景:

  • 文本去重:自媒体稿件库、工单库、日志摘要,靠向量相似度判断是不是重复内容,比MD5哈希这种精确匹配管用得多。
  • 文本聚类:把用户反馈按语义自动归堆,看看大家集中抱怨什么问题。
  • 内容推荐:给文章、商品标题算向量,找"看了这个还该看那个"。
  • 知识库问答:先向量检索召回相关片段,再把片段喂给大模型生成答案。这是最典型的 RAG 用法。

如果你只是临时比对几十条文本,用现成的向量库硬写都行,但一旦规模上千上万条,在线 API 的稳定性和效果优势就很明显了。

1.2 为什么选百炼通用文本向量,而不是本地模型

之前我在本地部署过开源的向量模型,效果其实不差,但维护成本高:模型文件几个G,GPU显存得常驻,还要考虑向量维度升级、中文分词适配、服务负载这些事。换个角度,如果只做几十万文本的索引,用在线API更划算,不用管推理资源,不用占显存,扩容也无感。

百炼通用文本向量最让我看中的是它的text_type参数。它把文本分成两类:document(文档端)和query(查询端)。这一点非常实用。为什么要分?因为训练模型时,文档和问题往往是不同风格的文本,把它们区分处理能显著提高检索精度。你可以在索引时把长文档标记为document,在检索时把用户问题标记为query,这样比统一用一个模型去向量化效果更好。

另外它的接入方式兼容 OpenAI 格式,意味着你在代码里只需要改一下 base_url 和 model 名字,很多已经写好的脚本就能直接复用。对我这种手里有大量历史代码的人来说非常省事。

当然本地模型也不是完全没有价值。离线环境、私有化部署、数据完全不能出内网,这些时候还是要自己扛模型。这次项目数据不敏感,规模也不大,我最终选了云上 API。如果你的数据有严格的合规要求,还是优先考虑私有化方案。

2. 核心原理与关键参数

2.1 文本变成向量,中间发生了什么

模型接收一段文本后,流程大致是分词、映射成 token、过编码器、池化、归一化。你可以把 token 理解成模型认识的"单词碎片",英文一个词可能拆成几个碎片,中文基本按字和词混合切分。编码器一层层变换后,每个 token 都对应一个中间向量,最后把所有 token 的向量做平均或者取首尾特殊标记的向量,得到整段文本的向量。

这个过程中的"相似",是模型在训练阶段从海量文本里学出来的语义接近,不是简单的字符串重合。比如关键词匹配认为"苹果"和"香蕉"没关系,但向量模型知道它们都属于水果,语义距离就比较近。

归一化这一步很关键。如果不做归一化,文本长度会直接影响向量长度,相似度计算容易偏向长篇内容。百炼接口返回的向量通常是归一化之后的,所以你可以直接用点积代替完整余弦计算,省一次运算。但我建议你在读代码时还是自己验证一下,可以把向量范数打印出来,接近 1 就说明已经归一化。

2.2 调用前必须搞清楚的几个参数

  • model:选用哪个向量模型,建议用默认的最新版本。模型版本会直接影响向量维度。不要从别人教程里抄死维度数字,要动态从返回结果里读。
  • text_type:query还是document。这个参数对检索效果很重要,很多人忽略它,导致召回质量明显下降。
  • 输入长度:单条文本超过模型上限会被截断。我在实践中的做法是控制单条在 500 字符以内,超长文本先做段落切分,再分别向量化。反正最终检索也是按片段召回,长文本一次塞进去本身就不合理。
  • 批量大小:接口支持一次传多条文本。我实测过,每次传 10 到 20 条比较稳妥,单条和批量按次计费,批量更划算。
  • embedding 维度:同一个模型服务版本稳定之后维度一般固定,但版本升级有可能会变,所以建表时不要用固定长度字段,或者至少留个迁移机制。

2.3 接口返回什么

一次典型调用返回的结构包含这几块:请求状态、输出列表、token 使用量。输出列表里每条向量和你的输入文本是一一对应的。因此批量请求时,服务端返回顺序通常和输入顺序一致,你可以按索引对齐。

我在代码里看到usage字段时还很意外,原来调用向量模型也会统计 token 消耗量。这个数字对成本核算很有用,可以自己在日志里累计一下,方便月底对账。

3. 实操:从拿到 Key 到跑通完整流程

3.1 环境准备与鉴权配置

先开通百炼平台的模型服务,拿到api-key,然后把它放到环境变量里。我习惯不把密钥写死在代码里,而是用DASHSCOPE_API_KEY环境变量。这样代码可以随手分享,也不用担心泄露。

export DASHSCOPE_API_KEY="你的密钥"

Python 环境我建议直接用官方 SDK,它封装了请求细节,也支持异步调用。没有 SDK 的话,用普通的requests库直接调 OpenAI 兼容接口也可以。下面两种方式我都试过,优先推荐 SDK,代码更简洁。

import os from dashscope import TextEmbedding def get_embedding(text: str, text_type: str = "document") -> list[float]: response = TextEmbedding.call( model="text-embedding-v3", input=text, text_type=text_type, api_key=os.environ.get("DASHSCOPE_API_KEY"), ) if response.status_code == 200: return response.output["embeddings"][0]["embedding"] else: raise RuntimeError(f"调用失败: {response.code}, {response.message}")

这里我故意用了text-embedding-v3这个名字,但实际模型标识符要以你在控制台能看到的为准。有些项目里显示的是text-embedding-v2或更新版本,都无所谓,关键是不要写死维度。

3.2 第一种接入方式:OpenAI 兼容模式

如果你的项目里已经用了 OpenAI 客户端,接入百炼最方便的方式是改 base_url。我试过用openaiPython 库,把 base_url 指向兼容模式的地址,再传api_key,代码几乎不用改。比如:

from openai import OpenAI client = OpenAI( api_key=os.environ.get("DASHSCOPE_API_KEY"), base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", ) resp = client.embeddings.create( model="text-embedding-v3", input=["设备无法正常启动"], encoding_format="float", ) vector = resp.data[0].embedding

这种方式的好处是,如果你的团队历史代码已经基于 OpenAI 生态,迁移成本极低。换个 base_url,其他部分照旧。官方 SDK 则对百炼平台的错误码和配额管理更友好。我更推荐后者。

3.3 批量构建文本索引:第一个完整方案

单条调用只能用来验证连通性。真正做项目至少要批量处理一批文本。我写了一个稍微完整一点的脚本,包含批量请求、兜底重试、结果保存。

import os import json import time from dashscope import TextEmbedding def embed_batch(texts, text_type="document", batch_size=10): vectors = [] for i in range(0, len(texts), batch_size): batch = texts[i:i + batch_size] for attempt in range(3): try: resp = TextEmbedding.call( model="text-embedding-v3", input=batch, text_type=text_type, api_key=os.environ.get("DASHSCOPE_API_KEY"), ) if resp.status_code == 200: vectors.extend(item["embedding"] for item in resp.output["embeddings"]) break elif resp.status_code == 429: time.sleep(2 ** attempt) else: raise RuntimeError(f"{resp.code}: {resp.message}") except Exception as e: if attempt == 2: raise e time.sleep(1) return vectors if __name__ == "__main__": docs = [ "设备无法正常启动,请检查电源连接", "开机后黑屏,风扇不转", "如何重置管理员密码", ] vecs = embed_batch(docs) print(f"共生成 {len(vecs)} 条向量, 维度: {len(vecs[0])}") with open("vectors.json", "w", encoding="utf-8") as f: json.dump({"docs": docs, "vectors": vecs}, f, ensure_ascii=False)

这里每批 10 条是经验值,太大会提高单次请求超时概率,太小又浪费配额。重试的设置很有必要,在线 API 在高峰时偶尔会 429 限流。我加了简单指数退避,两秒起步,第三次失败才抛出异常。

3.4 向量相似度计算:从索引到检索

向量生成以后,最高频的操作就是检索。最朴素的办法是拿待查询的向量和库里所有向量算余弦相似度,然后取 Top-K。代码很直白:

import numpy as np import json def cosine_similarity(a, b): a = np.asarray(a, dtype="float32") b = np.asarray(b, dtype="float32") return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def search(query_text, top_k=5): qvec = embed_batch([query_text], text_type="query")[0] with open("vectors.json", encoding="utf-8") as f: data = json.load(f) docs, vecs = data["docs"], data["vectors"] scored = [(cosine_similarity(qvec, v), d) for v, d in zip(vecs, docs)] scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k] for score, doc in search("开不了机"): print(f"{score:.4f}: {doc}")

这个版本在小规模数据集上完全够用。文本量到了十万级别,手动遍历就开始吃力,那时候再引入向量数据库或者类似 FAISS 的库不迟。我不建议一上来就上重型组件,先把 JSON 版本的跑通,理解整个链路,再谈加索引和分片。

3.5 百炼平台 + 向量存储:接近生产可用的一版

当文档数量上升,两个问题就会出现:第一,每次全量遍历算相似度太慢;第二,向量存在 JSON 文件里没法增量更新。我在这个阶段引入了 FAISS,因为它安装简单、单机就能跑,不需要额外搭服务。

import faiss import numpy as np import json class VectorStore: def __init__(self, dim): self.index = faiss.IndexFlatIP(dim) self.documents = [] def add(self, texts, text_type="document"): vecs = embed_batch(texts, text_type=text_type) self.documents.extend(texts) self.index.add(np.asarray(vecs, dtype="float32")) def query(self, text, top_k=5, text_type="query"): qvec = embed_batch([text], text_type=text_type) scores, idxs = self.index.search(np.asarray(qvec, dtype="float32"), top_k) return [(scores[0][i], self.documents[idxs[0][i]]) for i in range(len(idxs[0]))]

IndexFlatIP 是内积索引。如果向量已经归一化,内积等价于余弦相似度;如果没归一化,结果会有偏差。所以再次强调,先确认返回向量的范数是否接近 1,再决定用内积还是显式算余弦。

我这个类写得很精简,生产环境还要考虑持久化:保存 faiss 索引文件、保存文档列表、定期重建。索引文件用faiss.write_index写到磁盘,文档列表单独存个 JSON,两边顺序保持一致就行。

3.6 完整落地:一个最小知识库问答助手

把向量检索和生成模型串起来,才算真正发挥价值。这里我采用最直接的 RAG 流程:

  1. 离线阶段:把知识库文档按段落切分,每段向量化,存入 FAISS。
  2. 在线阶段:用户提问,先把问题向量化,再从索引里召回 Top-K 相关段落。
  3. 生成阶段:把召回的段落和问题一起提交给大模型,让它基于提供的资料回答。

我写了个简化版:

from openai import OpenAI import os client = OpenAI( api_key=os.environ.get("DASHSCOPE_API_KEY"), base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", ) def ask_question(question: str, store: VectorStore, top_k: int = 3) -> str: results = store.query(question, top_k=top_k) context_blocks = [f"[{i+1}] {doc}" for i, (_, doc) in enumerate(results)] prompt = "请只根据以下资料回答问题:\n" + "\n".join(context_blocks) + "\n\n问题:" + question resp = client.chat.completions.create( model="qwen-plus", messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content

这个代码里有一段值得注意:prompt 里明确写了"请只根据以下资料回答问题"。这是因为向量召回的片段可能不完整,大模型如果自由发挥,容易给出不在资料里的信息。评分可以从"是否准确引用来源"和"是否拒绝回答未知内容"两个维度观察。

4. 常见问题与排查技巧实录

4.1 高频报错:现象、原因、解法

我把自己和身边同事实际踩过的问题整理成了一张速查表:

现象常见原因解决办法
返回 401 或 InvalidApiKey环境变量没加载或密钥写错打印os.environ确认 key 存在;复制时注意别包含换行符
返回 404 或 ModelNotFound模型名不对,或所在区域未开通该模型到控制台确认可用的模型标识符,贴进代码
返回 429 或 QuotaExceeded请求太频繁,或账户余额/配额不足加指数退避重试;检查套餐剩余额度
报错 text length exceeds limit单条文本超过模型限制做段落切分,控制单条长度
返回的 embedding 维度与预期不符模型版本不同,或用了历史缓存数据打印len(embedding)动态获取,不要硬编码

第一条看起来简单,但真实发生频率最高。我习惯在代码入口处加一个 Debug:

assert os.environ.get("DASHSCOPE_API_KEY"), "缺少环境变量 DASHSCOPE_API_KEY"

这样至少能快速区分是配置问题还是接口问题。

4.2 效果不好时的排查顺序

如果你的检索结果明显不准,先不要急着换模型。我一般按这个顺序查:

第一,text_type用对没有。文档和问题都用document,效果会打折;都用query,文章类长文本也容易不稳定。我见过好几个项目效果差,最后全是因为这个参数没分开。

第二,切分长度合不合理。段落太长,向量被长度稀释;段落太短,语义不完整。我的经验是中文场景下 200 到 500 字一段比较合适,还要保证段落意思相对独立。你可以把文档先按标题、空行、句号做粗切,再合并到接近上限。

第三,评分阈值有没有拍脑袋。相似度 0.8 有没有?0.6 有没有?不同领域、不同文本风格差异很大。最靠谱的办法是拿一批人工标注的正负样本,计算实际分布,再定阈值。没有标注样本,至少打印一批召回的相似度分数,肉眼找拐点。

第四,是不是存在同义词但不可被向量模型区分的情况。通用向量模型对专业术语、代码片段、特殊符号的处理效果一般。我遇到过一次产品型号特别多的情况,向量召回总是混淆,最后的解法是在切分时把型号关键词额外拼接进文本,比如"设备型号 A-100 无法启动",召回精度明显改善。

4.3 线上运维的几个经验

向量服务接入业务后,很多问题跟调试阶段完全不同。分享三个我觉得最值得注意的:

缓存。同一个文本反复调向量接口很浪费。我在业务代码里加了一个简单的本地缓存,以文本 MD5 为 key,命中直接返回缓存向量。具体实现可以用 SQLite 或者 Pickle 文件。当文本集合相对固定时,缓存能把调用量降一个数量级。

监控。每次调用都可能失败,不能假设在线 API 永远可用。我在代码里对单次调用做了统计:成功数、失败数、平均延迟、token 消耗。任何指标单日突增,马上能感知到。

优雅降级。如果向量服务暂时不可用,检索接口要给出明确提示,而不是让用户看到超时。我常用的做法是设置max_retries和超时时间,超过阈值直接返回错误码,配合降级关键词搜索,保证主流程不彻底中断。

4.4 成本与配额管理的小账本

向量化是典型的按量计费服务,单次调用便宜,但量大了之后费用会变得很明显。我算过一笔账:假设一个知识库有 10 万条片段,每条平均 300 字,一次全量向量化的总 token 量大约对应数十万级 token。这个数字看着大,实际费用相对有限,但问题在于:如果你每次都重新全量向量化,成本会线性膨胀。

我的建议是增量更新。新增文档时只对新增片段做向量化,老片段全部走缓存。另一个建议是对文本做预处理:去 HTML 标签、压缩连续空白、去掉无意义头尾。这些都能减少 token 消耗,同时还能提高向量质量。

另一个容易被忽略的地方是测试时要控制输入量。我见过有人为一个字段值跑了上万次向量调用,最后查账才发现。测试阶段限定只处理前 100 条数据,验证通过再放量,是基本的成本纪律。

5. 个人经验:这套方案还能怎么扩展

最后分享一个我在实际项目中得出的判断。百炼通用文本向量不是一个封闭能力,它很适合跟其他组件拼出完整方案。比如我后来把向量检索结果和基于规则的过滤逻辑结合,先向量召回粗排,再用关键词规则精排,准确率比单独用向量高不少。原因是向量模型擅长找语义相近,规则擅长做精确约束,两者天然互补。

另一个扩展方向是文本聚类。我把用户反馈向量化以后,再跑一次简单的 KMeans,很快就分出了几个大主题。聚类结果不需要多精确,只要能引导人工去看哪些问题是集中爆发的就行,这项能力对做用户运营的团队价值很大。

如果你准备在自己项目里用它,我的建议是先花一个下午把本文的示例代码跑通,把链路理解透,再上生产。千万不要一上来就引入十几个依赖组件,那样出了问题你不知道是向量服务的问题,还是检索代码的问题,还是向量数据库配置的问题。

踩过几次坑之后,我现在写向量服务的调用代码都默认带上三条铁律:一是不硬编码模型版本和维度,二是所有外部调用必须要有错误处理和重试,三是数据落盘前先打印返回样本做 sanity check。这三条看着基础,但真能省下大量排查时间。

另外推荐把你自己的文本样例和相似度计算结果保存下来,隔段时间再跑一遍,观察有没有模型升级导致的分数漂移。向量模型版本更新后,相似度绝对值发生变化是正常的,关键是排序是否稳定。这个现象容易忽略,但对线上效果影响很大,值得持续关注。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进