# 向量库没装好,RAG 检索还能不能用——从 AI工厂管家社区版的三级降级链说起先把结论摆前面:**RAG 做降级本身不难,难的是降级的时候不返回“看着像那么

12 阅读10分钟

先把结论摆前面:RAG 做降级本身不难,难的是降级的时候不返回“看着像那么回事”的垃圾结果。开源项目落地,Milvus 没装、embedding 的 key 没配,是再正常不过的现场。AI工厂管家(AI Factory Manager)社区版把知识库检索做成了一条三级降级链,真正花心思的地方不在“能降”,而在两处护栏:一是 mock 模式下的确定性伪随机向量必须被双端拦下,二是 数据库主键 doc_id 和向量库 doc_id 是两套 id,一旦脱节,下架的内容照样会被召回。这篇拆这两处,代码能跑,坑也真踩过。

部署那天,向量库多半是不在的

做过私有化交付的人心里都有数:客户环境里真正装齐 Redis、Milvus、MinIO 的,是少数。更多情况是一台干净的服务器,.env 里 LLM 的 key 都还没填,运维先跑起来看看界面长什么样。这时候如果知识库检索硬依赖向量库,服务直接 500,第一印象就没了。

社区版在这件事上的取舍写在 README 里——Redis、Milvus、MinIO 都做成可降级,缺省走内存模式。知识库这一层同理:向量库不在、embedding 不可用,检索都得能出结果。麦肯锡那类制造业数字化转型调研里有个反复出现的结论,改造卡住的环节大多在数据资产和组织准备,而不是算法本身;放到 RAG 上就是,环境没齐是常态,能不能在残缺环境里给出可信的回答,比能不能跑满分更重要。

三级降级链不难写,难的是别把垃圾当答案

KnowledgeBase.search() 里的分支逻辑本身很直白,按手上有什么组件选策略:

  • 有 vector_store 也有 embedding_provider,走向量语义检索;
  • 只有 vector_store,退回 TF-IDF 相似度;
  • 两者都没有,用内存里的 TF-IDF,再不济用关键词重叠兜底。

写到这儿的版本是能跑的,也是很多项目停下来的地方。问题出在一个很隐蔽的细节上:embedding 的“模拟模式”。社区版的 embedding 提供者在两处会退化成 mock——没配 API key,或者底层依赖库(sentence-transformers、openai)没装上。mock 模式不是返回空,而是返回一个确定性伪随机向量:拿文本的 MD5 当随机种子,同一段文本永远得到同一个向量。

这个设计本身是为了让功能“看起来可用”,但它埋了个雷。假如代码只在“组件对象存在”时就走向量路径,那 query 的伪随机向量会去撞文档的伪随机向量——两者毫无语义关系,排序基本是随机的,可 cosine 分数却会给出一个像模像样的小正数。用户问一句工艺参数,系统煞有介事地返回一段报销流程,还带着 0.2 的相似度。这比直接报错危险得多,报错至少诚实。

一段能跑的代码:把护栏拆开看

下面这段要解决的问题很具体——证明不加护栏时向量检索会返回垃圾,而加了 is_mock 双端护栏后会老实走关键词降级。它把仓库里的两个真实细节抽了出来:MD5 种子的确定性伪随机向量,以及写入端/读取端两道 is_mock 判断。纯标准库,直接能跑:

# 降级链上的“伪随机向量”陷阱:mock 模式下,向量检索必须被拦下
import hashlib
import math
import random
import re

DIM = 64  # 真实仓库是 1024,这里缩到 64 只为跑得快


def deterministic_vector(text, dim=DIM):
    # 仓库里 mock embedding 的真实实现:MD5 当种子,同文本永远同向量
    seed = int(hashlib.md5(text.encode("utf-8")).hexdigest(), 16)
    rng = random.Random(seed)
    return [rng.uniform(-1.0, 1.0) for _ in range(dim)]


def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(x * x for x in b))
    return dot / (na * nb) if na and nb else 0.0


class TinyVectorStore:
    def __init__(self):
        self.rows = []  # (text, vector)

    def insert(self, text, vector):
        self.rows.append((text, vector))

    def search(self, query_vector, top_k=2):
        scored = [(t, round(cosine(query_vector, v), 3)) for t, v in self.rows]
        scored.sort(key=lambda x: x[1], reverse=True)
        return scored[:top_k]


DOCS = [
    "CNC 主轴转速与进给量的工艺参数对照表",
    "3 号产线换模具平均 40 分钟,先压换线次数",
    "报销流程:填单、主管审批、财务打款",
]


def build(store, guard):
    # guard=True 模拟仓库里 is_mock() 写入端护栏;False 模拟“忘了加护栏”的版本
    for d in DOCS:
        if guard:
            continue  # mock 向量不落库
        store.insert(d, deterministic_vector(d))


def keyword_hit(query):
    qc = set(re.findall(r"[\u4e00-\u9fff]", query))
    best, best_hit = "(无命中)", 0
    for d in DOCS:
        hit = len(qc & set(re.findall(r"[\u4e00-\u9fff]", d)))
        if hit > best_hit:
            best, best_hit = d, hit
    return best


def search(store, query, guard):
    if guard:
        return [("关键词降级", keyword_hit(query))]
    return store.search(deterministic_vector(query), top_k=2)


print("=== 忘了加护栏(naive:mock 向量照样入库、照样检索)===")
s1 = TinyVectorStore()
build(s1, guard=False)
for text, score in search(s1, "CNC 工艺参数怎么设", guard=False):
    print(f"  score={score:<6} {text}")

print("=== 加了 is_mock 双端护栏(写入不落库、检索走关键词降级)===")
s2 = TinyVectorStore()
build(s2, guard=True)
for tag, text in search(s2, "CNC 工艺参数怎么设", guard=True):
    print(f"  [{tag}] {text}")

跑出来的对比是这样的:

=== 忘了加护栏(naive:mock 向量照样入库、照样检索)===
  score=0.206  报销流程:填单、主管审批、财务打款
  score=-0.041 CNC 主轴转速与进给量的工艺参数对照表
=== 加了 is_mock 双端护栏(写入不落库、检索走关键词降级)===
  [关键词降级] CNC 主轴转速与进给量的工艺参数对照表

三个注意点。一,naive 版不是“检索不到”,而是“检索错了还很有底气”——报销流程拿到 0.206,比真正的 CNC 文档(-0.041)还高,这种错误混在正常结果里根本看不出来。二,伪随机向量是确定性的,所以这个 bug 会稳定复现,测试环境抓到一次,线上就是 100% 复现,别抱侥幸。三,护栏必须两头都加:写入端不加,脏向量已经落进库里了,之后就算读取端拦下来,库也被污染了;读取端不加,历史遗留的脏向量照样会被查出来。仓库里这两道判断分别落在 add_document(写入前 not self.embedding_provider.is_mock())和 search(向量检索前同样判断),缺一不可。

护栏一:mock 向量两头都要拦

把上面的结论落到仓库代码上,写入端在 add_document 里是这样收口的:只有 vector_store 和 embedding_provider 都在、且 is_mock() 为假时,才真正调 insert 把分块写进 Milvus;否则只写内存存储,chunk_ids 留空。upload_document 走的是同一套判断,并且把“跳过写入”这件事用 warning 级别记了日志——因为早期版本这里异常被 except 静默吞掉,向量化到底有没有生效,线上根本查不出来,属于典型的“看起来在工作”。

读取端在 search 里对应加了一道:is_mock() 为真就直接跳过向量检索,落到 _tfidf_search。这一条还有注释点破了原因——伪随机的 query 向量去命中伪随机的文档向量,出来的就是垃圾,宁可不要。两道护栏合起来,mock 环境下系统给出的答案虽然朴素,但至少来自真实的词面匹配,不是随机数。

护栏二:两套 doc_id 不对齐,下架等于没下

第二处更阴。知识库文档在 PostgreSQL 的 knowledge_documents 表里有个自增主键 doc_id,而写进向量库时用的是另一套 id(形如 DOC1727... 的时间戳串),存在向量记录的 metadata.doc_id 里。两套 id 各活各的,平时不出事,一旦要下架文档就露馅了。

PATCH /api/knowledge/documents/<doc_id>/status 把文档状态改成 archived 或 deprecated 时,如果只更新数据库那一行,向量库里对应的分块还躺在 Milvus 里,RAG 检索照样命中——界面上已经下架的内容,问答里还在被引用。这在合规要求高的场景是硬伤。仓库里的修法是先把向量库的关联 id 捞出来:文档落库时把 vector_doc_id 存进 extra_data,归档时读出来,调 vector_store.delete_by_doc_id 把分块清掉,再改数据库状态。清理失败也不阻塞状态变更,只留一条 warning 日志——取舍是“下架动作不能被向量库拖死”,可接受,但日志得留痕。

这里有个容易忽略的工程习惯:跨存储的关联 id 要显式持久化。别指望用内容哈希或者标题去反查,文档一改,哈希就变了,反查直接断链。把 vector_doc_id 和业务 doc_id 的映射写进 extra_data,是这套双 id 体系能对上号的前提。

冷启动还有一件事:重启后知识别丢

向量库不可用时会走内存存储,那服务一重启,内存就空了,知识库回到初始状态——这对“训练内容可检索”是个致命伤。社区版在 v6.30 补了这条:add_document 除了写向量库,还会把文档同步落一份到 knowledge_documents 表;进程启动时 load_from_db() 把 status=active 的文档重新灌回内存存储。注意它不重新做向量化,直接按关键词/TF-IDF 参与检索——毕竟重启时向量库可能还是不在,重新向量化既慢又可能再次写入 mock 脏向量,不如老老实实走降级。

顺带说下知识自动沉淀的去重。训练内容回灌知识库时,靠 extra_data 里的 source_key 做幂等键,同一来源重复灌不会在库里堆出多份,避免“同一份工艺文档被检索出三条几乎一样的片段”。这种幂等键在批量导入场景里很关键,漏了就是数据越用越脏。

边界说清楚:社区版守住的是基础知识库

得把话说在明处,免得评估的人上手才发现落差。社区版这部分给的是基础知识库这套通用能力:文档上传、自动向量化、RAG 检索、知识库外回答的录入。README 的“功能边界”里写得很清楚,行业垂直规则库、多租户隔离这类企业级能力留在商业版,不在本仓库。所以上面这套降级链是社区版的完整形态,不是阉割版——它本来就定位在“小库、少文档、环境不齐”的起步场景,真要上行业规则库,那是另一个交付层级的事。

收个尾

RAG 检索的降级链,写出来不难,难在降级之后给出的东西依然可信。mock 向量两头拦、双 doc_id 显式映射并联动清理,这两道护栏决定了系统是“诚实地说不知道”,还是“自信地胡答”。社区版把这两处都兜住了,加上弱命中过滤按向量库/内存两种分数语义分开判、小库不误杀,整套降级才算闭环。想看代码,把知识库模块单独拎出来读也不会被商业版能力劝退。