一、引言:行业知识从来不是单一形态
我在为一家大型制造企业搭建知识平台时,被业务方提了一个"简单"的需求:"把我们几十年的设备维修知识做成一个系统,工程师问什么,它就能答什么。"
需求听起来简单,做起来才意识到"知识"这个词有多复杂。工程师问"这台压缩机上次振动超标是怎么处理的",这需要语义检索 ------问法五花八门,关键词对不上;问"和这台压缩机同型号、且供应商相同、且近期做过大修的设备还有哪些",这需要关系推理 ------跨越多个实体、多种关联;问"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 融合后的可解释性
行业用户不信任黑盒。融合层必须能回答"为什么是这个结果"。我给每个返回结果附上source与score_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融合、主键打通,每一环都在为"行业用户能真正用起来"服务。知识平台的技术栈可以演进------向量库可以换、图库可以换------但只要统一层的契约与纪律在,换掉任何一个底层存储,上层业务都毫发无损。这,才是多模架构真正的复利。