从零搭建行业知识平台:向量库+图数据库+传统关系库的多模检索统一层设计

一、引言:行业知识从来不是单一形态

我在为一家大型制造企业搭建知识平台时,被业务方提了一个"简单"的需求:"把我们几十年的设备维修知识做成一个系统,工程师问什么,它就能答什么。"

需求听起来简单,做起来才意识到"知识"这个词有多复杂。工程师问"这台压缩机上次振动超标是怎么处理的",这需要语义检索 ------问法五花八门,关键词对不上;问"和这台压缩机同型号、且供应商相同、且近期做过大修的设备还有哪些",这需要关系推理 ------跨越多个实体、多种关联;问"2023年3月这台设备的维修单号和金额是多少",这需要精确查询------一个数字都不能错。

没有任何单一存储能同时优雅地满足这三类问题。向量库擅长语义近似,但说不清关系;图数据库擅长关联遍历,但做不了数值聚合;关系库擅长精确事务,但对自然语言一问三不知。行业知识的本质是多形态的,单一存储注定只能覆盖一个侧面。

本文记录我从零搭建这套知识平台时,在存储层之上设计的一个多模检索统一层:它把向量库、图数据库、关系库三套存储组织成一个逻辑整体,对外暴露一个统一检索接口,内部完成查询解析、路由与结果融合。

二、三库的定位:谁负责什么

先明确分工。我花了很长时间才想清楚一件事:不要试图让任何一套存储"全能",而是让每套存储做它最擅长的事,再通过统一层把它们的答案缝起来。

存储 擅长 典型问题 不擅长
向量库 语义相似、模糊匹配 "类似这种故障的现象还有哪些?" 精确值、多条件过滤、关系
图数据库 关联遍历、路径推理 "哪些设备受这次批次缺陷影响?" 数值计算、聚合统计
关系库 精确查询、事务、聚合 "该设备2023年的维修总费用?" 语义理解、模糊搜索

三套数据的建模也各自独立但共享主键:设备在关系库里有device_id与结构化字段,在向量库里以其维修文本的embedding存在,在图里是"设备-供应商-维修记录"的节点与边。统一层的第一个职责,就是让上层不需要知道"这条知识住在哪个库"。

三、统一层设计:一个接口,三套存储

3.1 查询的天然形态:问题先被"分诊"

统一层的核心洞察是:用户的问题在到达存储之前,应该先被拆解成"检索意图"。一个复杂问题往往同时包含语义部分、关系部分和精确部分。因此我设计了三个层次的组件:

  • Query Parser(查询解析):把自然语言解析成结构化的检索意图;
  • Router(路由):决定每个意图该去哪个存储执行;
  • Fusion(结果融合):把多路结果按相关性合并、去重、排序。
python 复制代码
from dataclasses import dataclass, field

@dataclass
class QueryIntent:
    type: str                # semantic | relational | exact | hybrid
    text: str                # 语义检索用原文
    filters: dict = field(default_factory=dict)   # 精确条件
    relation: str | None = None                    # 关系检索用路径
    top_k: int = 10

@dataclass
class SearchResult:
    doc_id: str
    score: float
    source: str              # vector | graph | relational
    snippet: str
    meta: dict = field(default_factory=dict)

class UnifiedSearch:
    def __init__(self, vector_store, graph_store, relational_store, parser):
        self.vector, self.graph, self.relational = vector_store, graph_store, relational_store
        self.parser = parser

    def search(self, question: str, top_k: int = 10) -> list[SearchResult]:
        intents = self.parser.parse(question)      # 问题分诊
        results: list[SearchResult] = []
        for intent in intents:
            if intent.type in ("semantic", "hybrid"):
                results += self.vector.query(intent.text, top_k=intent.top_k)
            if intent.type in ("relational", "hybrid"):
                results += self.graph.query(intent.relation, intent.filters)
            if intent.type == "exact":
                results += self.relational.query(intent.filters)
        return self.fusion.rerank(results, question)

3.2 查询解析:LLM负责"翻译",规则负责"兜底"

Query Parser我用"LLM + 规则"双通道实现。LLM负责把自然语言翻译成结构化意图------它擅长理解"和这台压缩机同型号、且供应商相同的设备"这种复杂表述;但LLM会幻觉,所以我叠加规则层做约束与兜底:实体识别用领域词典,数值条件用正则提取,LLM只负责"意图分类与关系路径构建"这种语义部分。

python 复制代码
class QueryParser:
    def __init__(self, entity_dict: dict, llm_parser=None):
        self.entity_dict = entity_dict      # 领域实体词典
        self.llm = llm_parser               # 可选的LLM意图解析器

    def parse(self, question: str) -> list[QueryIntent]:
        # 规则通道:先做确定性提取
        intents = []
        if self._has_exact_clue(question):        # 含明确ID/日期/金额
            intents.append(QueryIntent("exact",
                text=question, filters=self._extract_filters(question)))
        if self._has_relation_clue(question):     # 含"同型号/供应商/关联"
            intents.append(QueryIntent("relational",
                text=question, relation=self._extract_relation(question)))

        # LLM通道:语义与混合意图
        if self.llm is not None:
            llm_intents = self.llm(question)      # 返回结构化意图列表
            intents += [i for i in llm_intents
                        if i not in intents]      # 与规则结果去重

        # 兜底:总归有一条语义检索,保证任何问题都有响应
        if not intents:
            intents.append(QueryIntent("semantic", text=question))
        return intents

这个设计的纪律是:规则结果优先、LLM结果补充、语义检索兜底。规则能确定的绝不让LLM猜,LLM能做的绝不让用户手动结构化,而语义检索保证系统"永远不会没结果"------这在行业场景里是可用性的底线。

四、三套存储的具体实现

4.1 向量库:语义检索的实现

向量库存的是知识文本的embedding。我选型时的一个关键决策是向量库必须支持元数据过滤------行业检索几乎总是"语义+条件"的组合("类似这个故障,且设备在华东区"),纯语义检索在行业里价值有限。

python 复制代码
class VectorStore:
    def __init__(self, embed_fn, index=None):
        self.embed = embed_fn                 # 文本 → 向量
        self.index = index                    # 如 faiss / milvus 客户端

    def add_doc(self, doc_id: str, text: str, metadata: dict):
        vec = self.embed(text)
        self.index.add(vectors=[vec], ids=[doc_id], metadata=metadata)

    def query(self, text: str, top_k: int = 10,
              filters: dict | None = None) -> list[SearchResult]:
        vec = self.embed(text)
        hits = self.index.search(vec, top_k=top_k, filter=filters)
        return [SearchResult(doc_id=h.id, score=h.score,
                             source="vector", snippet=h.text,
                             meta=h.metadata) for h in hits]

嵌入模型的选择值得多说一句:通用embedding模型在行业术语上表现差------"轴承抱死"和"轴承烧毁"在通用模型眼里可能距离很远。我最终用领域语料微调了embedding模型,并用一批"专家认定的相似对"做了评测,相似度检索的命中率提升了约30%。行业知识平台里,embedding质量对效果的影响,往往大于存储和检索工程本身。

4.2 图数据库:关系推理的实现

图里存的是实体与关系:设备、供应商、维修记录、零件、人员,以及它们之间的边。图检索的价值在于多跳推理------"受同一批次缺陷影响的设备"在关系库里要写一串join,在图里就是一次遍历。

python 复制代码
class GraphStore:
    def __init__(self, driver):      # 如 neo4j driver
        self.driver = driver

    def query(self, relation: str, filters: dict, limit: int = 20) -> list[SearchResult]:
        # relation 是解析器产出的路径模板,如 "设备-[:同型号]->设备"
        cypher = f"""
        MATCH {relation}
        WHERE $filters  // 注入过滤器,注意参数化防注入
        RETURN startNode(r) AS src, endNode(r) AS dst, r.score AS score
        ORDER BY score DESC LIMIT $limit
        """
        with self.driver.session() as s:
            rows = s.run(cypher, filters=filters, limit=limit)
            return [SearchResult(doc_id=str(r["dst"]["id"]),
                                 score=r["score"], source="graph",
                                 snippet=f"{r['src']['name']} → {r['dst']['name']}")
                    for r in rows]

图检索的工程要点是路径模板白名单 :关系路径(-[:同型号]->)绝不能由LLM自由生成后直接拼进Cypher------那等于把查询语言暴露给了注入。我维护一张"关系路径模板表",LLM只能从中选择模板,参数全部走参数化查询。

4.3 关系库:精确查询的实现

关系库承担精确与聚合类查询:维修单号、日期、金额、状态统计。它是最"传统"的部分,但统一层给它的要求是:对外隐藏SQL,对内参数化

python 复制代码
class RelationalStore:
    def __init__(self, pool):       # 连接池
        self.pool = pool

    def query(self, filters: dict, limit: int = 20) -> list[SearchResult]:
        # filters 由解析器产出,字段名来自白名单 schema,杜绝任意 SQL
        sql = "SELECT * FROM device_repair WHERE 1=1"
        params = []
        for key, val in filters.items():
            if key in ALLOWED_COLUMNS:          # 列名白名单
                sql += f" AND {key} = ?"
                params.append(val)
        sql += f" LIMIT {limit}"
        with self.pool.acquire() as conn:
            rows = conn.execute(sql, params).fetchall()
            return [SearchResult(doc_id=r["id"], score=1.0,
                                 source="relational", snippet=str(r)) for r in rows]

列名白名单是这条路的红线:LLM解析出的过滤条件,字段名必须来自预定义schema,值必须走参数绑定。行业知识平台里,查询层是注入攻击的高发位,这条约束我从第一天就定死。

五、结果融合:多路结果的"统一度量"

三路结果都回来了,怎么合并排序?这是统一层里最容易做坏、也最决定体验的一环。直接拼接或简单加权都不可靠------不同存储的score不可比:向量库是余弦相似度,图是路径权重,关系库是精确命中。

5.1 用RRF做无参数融合

我选用Reciprocal Rank Fusion(RRF)------它不比较原始分数,只比较排名,天然规避了跨源分数不可比的问题:

python 复制代码
class RRFusion:
    def __init__(self, k: int = 60):
        self.k = k        # RRF 常数,常用 60

    def rerank(self, results: list[SearchResult],
               question: str) -> list[SearchResult]:
        # 先按 source 分组,各自按原始分排序取排名
        by_source: dict[str, list[SearchResult]] = {}
        for r in results:
            by_source.setdefault(r.source, []).append(r)

        agg: dict[str, float] = {}
        doc_map: dict[str, SearchResult] = {}
        for src, items in by_source.items():
            ranked = sorted(items, key=lambda r: -r.score)
            for rank, r in enumerate(ranked, start=1):
                agg[r.doc_id] = agg.get(r.doc_id, 0.0) + 1.0 / (self.k + rank)
                doc_map[r.doc_id] = r

        merged = sorted(agg.items(), key=lambda kv: -kv[1])
        return [doc_map[did] for did, _ in merged]

RRF有个被低估的好处:天然奖励"多路命中"。同一个doc_id如果既被向量路命中、又被图路命中,它的RRF分必然高于单路命中------这在行业场景里恰好符合直觉:多源印证的知识更可信。我把"命中源数量"作为一条元信息暴露给上层,前端甚至可以显示"该结论由语义+关系双路印证"。

5.2 融合后的可解释性

行业用户不信任黑盒。融合层必须能回答"为什么是这个结果"。我给每个返回结果附上sourcescore_breakdown:

python 复制代码
@dataclass
class SearchResult:   # 扩展
    ...
    sources_hit: list[str] = field(default_factory=list)   # 命中的存储列表
    reason: str = ""   # 如 "语义匹配0.82 + 关系印证(同型号设备)"

    def explain(self) -> str:
        return (f"来源:{'+'.join(self.sources_hit)} "
                f"| 相似度:{self.score:.2f} | {self.reason}")

六、工程实践中的五个关键决策

1. 三库同步是最大的隐性成本。 同一份知识在三套存储里各有一份,写路径必须保证一致性。我采用"关系库为源、异步双写"的策略:关系库是唯一事实源,变更事件进消息队列,向量与图异步更新,统一层对"短暂不一致"用TTL缓存容忍。切忌三套存储各自为政地写入------那会让一致性噩梦变成日常。

2. 主键打通是融合的前提。 三库共享同一个doc_id/实体ID体系,融合层才能按ID合并结果。我在建模阶段就统一了ID规范,这比任何融合算法都更重要------ID都不通,RRF再妙也无从合并。

3. 解析器输出必须经过schema校验。 LLM产出的意图(字段名、关系模板、过滤值)一律过白名单校验,校验不过的降级为纯语义检索。宁可少一路,不可错一路。

4. 延迟预算要按"路"分配。 一个混合查询可能同时打三个库。我给每路设独立超时(语义150ms、关系200ms、精确100ms),超时的路直接丢弃、不让整体等待。行业场景宁可少一个来源,也不能让用户等2秒。

5. 评测集先于上线。 我准备了一批"专家标注的标准问答对",覆盖语义/关系/精确/混合四类,上线前和每次改动后跑全量,用"前三命中率"与"答案正确率"两个指标把关。没有评测集,统一层的每次调优都是凭感觉。

七、总结

从零搭建这套行业知识平台,我最大的体会是:多模不是目的,是行业知识形态的必然结果。语义、关系、精确三类问题在行业场景里天然共存,任何单一存储都是残缺的。而统一层的价值,不在于"多了一个抽象",而在于把三套存储的差异消化在内部,让上层只面对一个接口、一份结果、一种解释。

这套架构上线后,工程师的检索体验发生了质的变化:语义检索找到了关键词搜不到的经验文档,关系检索顺藤摸瓜发现了同批次隐患设备,精确查询一秒钟给出财务需要的数字------而这一切,用户感知到的只是"同一个搜索框"。

多模检索统一层不是一个高深的技术,但它是一个必须做对的设计:查询分诊、白名单约束、RRF融合、主键打通,每一环都在为"行业用户能真正用起来"服务。知识平台的技术栈可以演进------向量库可以换、图库可以换------但只要统一层的契约与纪律在,换掉任何一个底层存储,上层业务都毫发无损。这,才是多模架构真正的复利。

相关推荐
程序员夏洛14 分钟前
Redis 中如何实现分布式锁?
数据库·redis·分布式
whcyhhh22 分钟前
头歌实践教学平台:大数据存储2023(十三3)
大数据·开发语言·python
AR-26710-30 分钟前
机器学习复习Day8——异常检测
人工智能·python·机器学习·scikit-learn
千里码aicood35 分钟前
Django 基于Django的电脑推荐与分析可视化系统设计与实现
python·django·电脑
2601_9620652537 分钟前
2.启动mysql容器
java·数据库·mysql
未若君雅裁43 分钟前
Agent 的结构化输出,ProviderStrategy、ToolStrategy 与错误重试
python·langchain
洛兮银儿1 小时前
pycharm选中同一个词的标记颜色的修改
ide·python·pycharm
2601_962381581 小时前
VSCode如何配置LlamaIndex RAG(检索增强生成)应用开发环境
vscode·开发环境·rag·llamaindex·检索增强生成
郝学胜_神的一滴1 小时前
Effective Python 条款 6:善用拆包告别恼人的下标索引
python·pycharm