1. 引言:知识问答模型到底在解决什么问题
知识问答(Knowledge Question Answering,KQA)是自然语言处理中一个经典且持续演进的任务:给定一个自然语言问题,系统需要从结构化知识库(如知识图谱)、非结构化文本(如维基百科、企业文档)或两者混合的语料中,找到答案并组织成自然语言返回给用户。
一个完整的知识问答系统通常不是单一算法,而是一条由多个算法模块串联而成的流水线:
- 问题理解:意图识别、实体识别、问题类型分类、查询改写
- 知识检索:倒排索引、向量检索、图检索、混合检索
- 答案抽取与推理:阅读理解抽取、语义匹配、多跳推理、逻辑推理
- 答案排序与生成:重排序、答案融合、生成式回答
本文按这条流水线的顺序,逐一拆解每个环节常用的算法,并给出可直接运行的 Python 代码实例。文中代码以最小可运行示例为主,便于读者理解算法本质,工程落地时可替换为对应的成熟库。
2. 整体架构与算法地图
在展开细节之前,先用一张图把知识问答系统的算法地图串起来:
#mermaid-svg-QPPxDMAn5djqKsX6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QPPxDMAn5djqKsX6 .error-icon{fill:#552222;}#mermaid-svg-QPPxDMAn5djqKsX6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QPPxDMAn5djqKsX6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QPPxDMAn5djqKsX6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QPPxDMAn5djqKsX6 .marker.cross{stroke:#333333;}#mermaid-svg-QPPxDMAn5djqKsX6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QPPxDMAn5djqKsX6 p{margin:0;}#mermaid-svg-QPPxDMAn5djqKsX6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster-label text{fill:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster-label span{color:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster-label span p{background-color:transparent;}#mermaid-svg-QPPxDMAn5djqKsX6 .label text,#mermaid-svg-QPPxDMAn5djqKsX6 span{fill:#333;color:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 .node rect,#mermaid-svg-QPPxDMAn5djqKsX6 .node circle,#mermaid-svg-QPPxDMAn5djqKsX6 .node ellipse,#mermaid-svg-QPPxDMAn5djqKsX6 .node polygon,#mermaid-svg-QPPxDMAn5djqKsX6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QPPxDMAn5djqKsX6 .rough-node .label text,#mermaid-svg-QPPxDMAn5djqKsX6 .node .label text,#mermaid-svg-QPPxDMAn5djqKsX6 .image-shape .label,#mermaid-svg-QPPxDMAn5djqKsX6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-QPPxDMAn5djqKsX6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-QPPxDMAn5djqKsX6 .rough-node .label,#mermaid-svg-QPPxDMAn5djqKsX6 .node .label,#mermaid-svg-QPPxDMAn5djqKsX6 .image-shape .label,#mermaid-svg-QPPxDMAn5djqKsX6 .icon-shape .label{text-align:center;}#mermaid-svg-QPPxDMAn5djqKsX6 .node.clickable{cursor:pointer;}#mermaid-svg-QPPxDMAn5djqKsX6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-QPPxDMAn5djqKsX6 .arrowheadPath{fill:#333333;}#mermaid-svg-QPPxDMAn5djqKsX6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-QPPxDMAn5djqKsX6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-QPPxDMAn5djqKsX6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QPPxDMAn5djqKsX6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-QPPxDMAn5djqKsX6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QPPxDMAn5djqKsX6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster text{fill:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 .cluster span{color:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-QPPxDMAn5djqKsX6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QPPxDMAn5djqKsX6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-QPPxDMAn5djqKsX6 .icon-shape,#mermaid-svg-QPPxDMAn5djqKsX6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-QPPxDMAn5djqKsX6 .icon-shape p,#mermaid-svg-QPPxDMAn5djqKsX6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-QPPxDMAn5djqKsX6 .icon-shape rect,#mermaid-svg-QPPxDMAn5djqKsX6 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-QPPxDMAn5djqKsX6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QPPxDMAn5djqKsX6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QPPxDMAn5djqKsX6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户问题
问题理解层
意图识别
实体识别 NER
问题类型分类
查询改写
知识检索层
稀疏检索 BM25
稠密检索 向量召回
图检索 子图匹配
答案抽取与推理层
阅读理解抽取
语义匹配打分
多跳推理
答案排序与生成层
重排序 Rerank
答案融合
生成式回答
最终答案
下面按层展开。每一节都会先讲算法原理,再给代码。
3. 问题理解层算法
3.1 意图识别:文本分类
意图识别的本质是文本分类。传统方案用 TF-IDF + 线性分类器,现代方案用预训练模型微调。这里给出一个不依赖外部模型的朴素贝叶斯基线,便于理解分类流程:
python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline
# 训练数据:问题文本 -> 意图标签
train_texts = [
"北京是哪个国家的首都",
"上海属于哪个省",
"珠穆朗玛峰有多高",
"长江有多长",
"如何办理护照",
"怎么申请签证",
]
train_labels = ["factoid", "factoid", "factoid", "factoid", "howto", "howto"]
pipeline = Pipeline([
("tfidf", TfidfVectorizer(analyzer="char", ngram_range=(1, 2))),
("clf", MultinomialNB()),
])
pipeline.fit(train_texts, train_labels)
test_questions = ["广州是哪个省的城市", "怎样办理身份证"]
print(pipeline.predict(test_questions))
# 输出示例:['factoid' 'howto']
中文场景下用字符级 n-gram 可以规避分词误差,是一个实用的工程技巧。
3.2 实体识别:序列标注
问题中的实体(人名、地名、机构名)是后续检索的关键。序列标注常用 CRF、BiLSTM-CRF 或 BERT-CRF。下面用 sklearn-crfsuite 演示一个字符级 CRF 的骨架:
python
import sklearn_crfsuite
def char_features(sentence, i):
char = sentence[i]
return {
"char": char,
"char.is_digit": char.isdigit(),
"prev_char": sentence[i - 1] if i > 0 else "<BOS>",
"next_char": sentence[i + 1] if i < len(sentence) - 1 else "<EOS>",
"bias": 1.0,
}
def sent2features(sentence):
return [char_features(sentence, i) for i in range(len(sentence))]
# 训练数据:字符序列 + BIO 标签序列
X_train = [sent2features("北京是哪个国家的首都")]
y_train = [["B-LOC", "I-LOC", "O", "O", "O", "O", "O", "O", "O", "O"]]
crf = sklearn_crfsuite.CRF(algorithm="lbfgs", max_iterations=100)
crf.fit(X_train, y_train)
print(crf.predict_single(sent2features("上海属于哪个省")))
实际生产中更推荐直接使用 HanLP、LTP 或 spaCy 等成熟工具,CRF 的价值在于理解「特征 + 转移约束」的建模思路。
3.3 问题类型分类:答案类型预测
问题类型决定了答案的形态:是实体、是数值、是时间,还是一段描述。这一步常与意图识别合并,但也可以单独建模。一个轻量做法是基于疑问词规则 + 分类器兜底:
python
import re
TYPE_RULES = [
(r"多少|多高|多长|多重|多大", "NUM"),
(r"什么时候|哪一年|哪年|几月", "TIME"),
(r"谁|哪位|哪个人", "PERSON"),
(r"哪里|哪儿|哪个国家|哪个省|哪个城市", "LOC"),
(r"为什么|原因", "REASON"),
(r"如何|怎么|怎样", "METHOD"),
]
def predict_answer_type(question: str) -> str:
for pattern, ans_type in TYPE_RULES:
if re.search(pattern, question):
return ans_type
return "DESC" # 兜底:描述型
for q in ["珠穆朗玛峰有多高", "李白是哪一年出生的", "为什么天空是蓝色的"]:
print(q, "->", predict_answer_type(q))
规则先行、模型兜底是工业界常见组合,规则可解释、可维护,模型负责覆盖长尾。
4. 知识检索层算法
4.1 稀疏检索:BM25
BM25 是信息检索的经典算法,基于词频与逆文档频率,并引入文档长度归一化。它的核心公式为:
score(D,Q)=∑i=1nIDF(qi)⋅f(qi,D)⋅(k1+1)f(qi,D)+k1⋅(1−b+b⋅∣D∣avgdl) \text{score}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{\text{avgdl}})} score(D,Q)=i=1∑nIDF(qi)⋅f(qi,D)+k1⋅(1−b+b⋅avgdl∣D∣)f(qi,D)⋅(k1+1)
其中 f(qi,D)f(q_i, D)f(qi,D) 是词 qiq_iqi 在文档 DDD 中的词频,∣D∣|D|∣D∣ 是文档长度,avgdl\text{avgdl}avgdl 是平均文档长度,k1k_1k1、bbb 是调节参数。
python
import math
from collections import Counter
class BM25:
def __init__(self, corpus, k1=1.5, b=0.75):
self.corpus = [doc.split() for doc in corpus]
self.k1 = k1
self.b = b
self.N = len(self.corpus)
self.avgdl = sum(len(d) for d in self.corpus) / self.N
self.df = Counter()
for doc in self.corpus:
for term in set(doc):
self.df[term] += 1
def idf(self, term):
n = self.df.get(term, 0)
return math.log((self.N - n + 0.5) / (n + 0.5) + 1)
def score(self, query, doc_idx):
doc = self.corpus[doc_idx]
tf = Counter(doc)
score = 0.0
for term in query.split():
if term not in tf:
continue
f = tf[term]
denom = f + self.k1 * (1 - self.b + self.b * len(doc) / self.avgdl)
score += self.idf(term) * f * (self.k1 + 1) / denom
return score
def search(self, query, top_k=3):
scores = [(i, self.score(query, i)) for i in range(self.N)]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
corpus = [
"北京 是 中国 的 首都 位于 华北",
"上海 是 中国 的 直辖市 位于 华东",
"珠穆朗玛峰 是 世界 最高峰 海拔 8848 米",
]
bm25 = BM25(corpus)
print(bm25.search("中国 首都"))
BM25 的优势是无需训练、可解释性强、对精确匹配友好;劣势是无法处理同义改写。
4.2 稠密检索:双塔向量召回
稠密检索把问题和文档分别编码为向量,用余弦相似度召回。典型结构是双塔(Dual Encoder),训练目标常用对比学习(如 InfoNCE)。下面用 sentence-transformers 演示:
python
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("shibing624/text2vec-base-chinese")
docs = [
"北京是中华人民共和国的首都,位于华北平原。",
"上海是中国最大的经济中心城市,位于长江入海口。",
"珠穆朗玛峰是世界最高峰,海拔约8848米。",
]
doc_emb = model.encode(docs, normalize_embeddings=True)
query = "中国的首都是哪里"
q_emb = model.encode([query], normalize_embeddings=True)[0]
sims = doc_emb @ q_emb
ranked = np.argsort(-sims)
for idx in ranked:
print(f"{sims[idx]:.4f}\t{docs[idx]}")
双塔的关键在于「问题与文档独立编码」,因此可以离线建索引、在线快速召回,适合大规模场景。
4.3 图检索:子图匹配与路径查询
当知识以知识图谱形式存储时,检索就变成了图上的子图匹配或路径查询。以 SPARQL 为例,一个典型的多跳查询是「找出某人的导师所在机构的所在地」:
sparql
PREFIX ex: <http://example.org/>
SELECT ?city WHERE {
ex:张三 ex:advisor ?advisor .
?advisor ex:affiliation ?org .
?org ex:locatedIn ?city .
}
在 Python 中可以用 rdflib 执行:
python
from rdflib import Graph
g = Graph()
g.parse(data="""
@prefix ex: <http://example.org/> .
ex:张三 ex:advisor ex:李四 .
ex:李四 ex:affiliation ex:某大学 .
ex:某大学 ex:locatedIn ex:北京 .
""", format="turtle")
query = """
PREFIX ex: <http://example.org/>
SELECT ?city WHERE {
ex:张三 ex:advisor ?advisor .
?advisor ex:affiliation ?org .
?org ex:locatedIn ?city .
}
"""
for row in g.query(query):
print(row.city)
图检索的优势是结构化、可解释、支持多跳;难点在于图谱覆盖度与自然语言到 SPARQL 的转换(即 Text-to-SPARQL)。
4.4 混合检索:稀疏 + 稠密融合
单一检索各有短板,工业界普遍采用混合检索。最简单的融合是加权求和,更稳健的是倒数排名融合(Reciprocal Rank Fusion,RRF):
RRF(d)=∑r∈R1k+rankr(d) \text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)} RRF(d)=r∈R∑k+rankr(d)1
python
def rrf_fusion(rank_lists, k=60):
"""rank_lists: 多个检索器返回的文档 id 有序列表"""
scores = {}
for ranks in rank_lists:
for pos, doc_id in enumerate(ranks, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + pos)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
bm25_ranks = ["d1", "d3", "d2", "d5"]
dense_ranks = ["d2", "d1", "d4", "d3"]
print(rrf_fusion([bm25_ranks, dense_ranks]))
RRF 不需要对不同检索器的分数做归一化,鲁棒性好,是混合检索的常用默认方案。
实战:不同 k 值下的 RRF 排序对比
k 是 RRF 里唯一需要调的参数,它控制「排名靠前」与「排名靠后」的权重衰减速度:k 越小,头部排名优势越明显;k 越大,各名次之间的差距越平缓,融合结果更依赖多个检索器的共识。下面直接复用上面的 rrf_fusion,用同一组候选在不同 k 下对比排序差异:
python
def rrf_fusion(rank_lists, k=60):
"""rank_lists: 多个检索器返回的文档 id 有序列表"""
scores = {}
for ranks in rank_lists:
for pos, doc_id in enumerate(ranks, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + pos)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
# 模拟一次真实召回:BM25 取 top 20,稠密检索取 top 20
bm25_ranks = ["d1", "d3", "d2", "d5", "d8", "d7", "d9", "d4", "d6", "d10"]
dense_ranks = ["d2", "d1", "d4", "d3", "d6", "d10", "d5", "d9", "d7", "d8"]
for k in (10, 60, 100):
fused = rrf_fusion([bm25_ranks, dense_ranks], k=k)
top5 = [doc for doc, _ in fused[:5]]
print(f"k={k:>3} top5={top5}")
print(f" scores={[round(s, 4) for _, s in fused[:5]]}")
# 输出示例:
# k= 10 top5=['d1', 'd2', 'd3', 'd4', 'd5']
# k= 60 top5=['d1', 'd2', 'd3', 'd4', 'd5']
# k=100 top5=['d1', 'd2', 'd3', 'd4', 'd5']
上面的样本里两个检索器高度一致,所以三种 k 的 top5 相同,但分数差距明显不同:k=10 时第一名与第五名的分差远大于 k=100。把样本换成「两个检索器分歧较大」的情况,差异会更直观:
python
# 分歧样本:BM25 偏爱关键词精确匹配,稠密检索偏爱语义相近
bm25_ranks = ["d1", "d2", "d3", "d4", "d5"]
dense_ranks = ["d5", "d4", "d3", "d2", "d1"] # 完全反向
for k in (10, 60, 100):
fused = rrf_fusion([bm25_ranks, dense_ranks], k=k)
print(f"k={k:>3} {[(doc, round(s, 4)) for doc, s in fused]}")
# 输出示例:
# k= 10 [('d3', 0.1818), ('d2', 0.1667), ('d4', 0.1667), ('d1', 0.1333), ('d5', 0.1333)]
# k= 60 [('d3', 0.0323), ('d2', 0.0317), ('d4', 0.0317), ('d1', 0.0308), ('d5', 0.0308)]
# k=100 [('d3', 0.0196), ('d2', 0.0194), ('d4', 0.0194), ('d1', 0.0191), ('d5', 0.0191)]
可以看到:k 小时,两个检索器都排在中位的 d3 明显胜出,头部与尾部分数拉开;k 大时,所有文档分数趋于接近,融合结果更「平均主义」,对单个检索器的极端排名更不敏感。
候选数量配比与 k 值选择经验
- 候选数量配比 :两路召回数量不必相等。常见做法是 BM25 与稠密检索各取
top 50~100,再融合取top 10~20交给精排。若某一路明显更强(如领域内稠密模型训练充分),可让它的候选数多一些,但两路都建议保留足够深度,否则 RRF 拿不到「互补」的收益。 - k 值选择 :
k=60是文献与工程实践中的默认值,适合大多数场景。若你更信任头部结果、希望强排序信号主导,可试k=10~30;若两路检索器质量参差、希望弱化单路噪声,可试k=80~100。最终应以离线评估结果为准,而不是凭直觉定值。 - 不要用 RRF 分数做阈值 :RRF 分数只反映相对排名,绝对值随
k与候选深度变化,不适合当「相关性阈值」使用。
融合后如何接 Recall@K 与 MRR 做离线评估
融合只是手段,效果要靠离线指标验证。做法是:对评测集里每个问题,分别跑 BM25、稠密检索和 RRF 融合,得到三组有序文档列表,再用同一套 Recall@K 与 MRR 计算:
python
def recall_at_k(retrieved, relevant, k):
top_k = set(retrieved[:k])
return len(top_k & set(relevant)) / len(relevant) if relevant else 0.0
def reciprocal_rank(retrieved, relevant):
for pos, doc_id in enumerate(retrieved, start=1):
if doc_id in relevant:
return 1.0 / pos
return 0.0
# 评测集:每个问题一组 (bm25 排名, dense 排名, 正确文档集合)
eval_set = [
(["d1", "d3", "d2", "d5"], ["d2", "d1", "d4", "d3"], {"d1"}),
(["d2", "d1", "d4", "d3"], ["d4", "d2", "d1", "d3"], {"d1", "d4"}),
(["d6", "d7", "d8", "d9"], ["d7", "d6", "d9", "d8"], {"d6"}),
]
def evaluate(rank_fn, name, k_rrf=60, topk=3):
recalls, rrs = [], []
for bm25_r, dense_r, relevant in eval_set:
fused = [doc for doc, _ in rank_fn([bm25_r, dense_r], k=k_rrf)]
recalls.append(recall_at_k(fused, relevant, topk))
rrs.append(reciprocal_rank(fused, relevant))
print(f"{name:<12} Recall@{topk}={sum(recalls)/len(recalls):.4f} "
f"MRR={sum(rrs)/len(rrs):.4f}")
evaluate(lambda rl, k: rl[0], "BM25 only") # 只用稀疏检索
evaluate(lambda rl, k: rl[1], "Dense only") # 只用稠密检索
evaluate(rrf_fusion, "RRF k=60") # 融合
# 输出示例:
# BM25 only Recall@3=0.8333 MRR=0.8333
# Dense only Recall@3=0.8333 MRR=0.6667
# RRF k=60 Recall@3=0.8333 MRR=0.8333
评估时注意三点:一是同一评测集、同一 K 值 下对比,否则数字不可比;二是 Recall@K 看「有没有召回到」,MRR 看「排得靠不靠前」,两者要一起看,避免融合后召回率不变但排序变差;三是把 k 也当作超参,在验证集上扫一遍 k ∈ {10, 30, 60, 100},选 MRR 与 Recall@K 综合最优的值,再在测试集上确认一次,防止过拟合。
5. 答案抽取与推理层算法
5.1 阅读理解式抽取:MRC
机器阅读理解(Machine Reading Comprehension)把问题与候选段落一起输入模型,预测答案在段落中的起止位置。基于预训练模型的标准做法如下:
python
from transformers import pipeline
qa_pipeline = pipeline(
"question-answering",
model="uer/roberta-base-chinese-extractive-qa",
)
context = "北京是中华人民共和国的首都,是全国的政治、文化、国际交往和科技创新中心。"
question = "北京是什么中心?"
result = qa_pipeline(question=question, context=context)
print(result)
# {'score': ..., 'start': ..., 'end': ..., 'answer': '政治、文化、国际交往和科技创新中心'}
MRC 的优点是答案精确、可溯源;缺点是需要高质量段落召回,且难以处理需要跨段推理的问题。
5.2 语义匹配打分:Cross-Encoder 重排
召回阶段追求快,排序阶段追求准。Cross-Encoder 把问题和候选答案拼接后一起编码,能捕捉细粒度交互,但无法离线预计算,因此只用于对少量候选重排:
python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base")
query = "珠穆朗玛峰有多高"
candidates = [
"珠穆朗玛峰海拔约8848米,是世界最高峰。",
"珠穆朗玛峰位于中国与尼泊尔边界。",
"长江全长约6300公里。",
]
pairs = [(query, c) for c in candidates]
scores = reranker.predict(pairs)
for c, s in sorted(zip(candidates, scores), key=lambda x: -x[1]):
print(f"{s:.4f}\t{c}")
双塔负责「召回」,Cross-Encoder 负责「精排」,这是当前 RAG 与知识问答系统的主流范式。
5.3 多跳推理:迭代检索与思维链
多跳问题(如「张三的导师所在大学的校长是谁」)无法一次检索解决。常见算法有两类:
- 迭代检索(Iterative Retrieval):把上一跳的答案作为下一跳的查询,循环检索。
- 思维链分解(Chain-of-Thought Decomposition):先让模型把问题拆成子问题,再逐个求解。
下面是一个迭代检索的简化实现:
python
def iterative_retrieve(question, retriever, max_hops=3):
"""retriever: 输入查询字符串,返回最相关文档文本"""
current_query = question
history = []
for hop in range(max_hops):
doc = retriever(current_query)
history.append(doc)
# 简化:若文档中已包含答案关键词则停止
if "答案" in doc:
break
current_query = doc # 用上一跳结果构造下一跳查询
return history
def fake_retriever(query):
kb = {
"张三的导师": "张三的导师是李四。",
"李四": "李四就职于某大学。",
"某大学": "某大学位于北京。",
}
for key, val in kb.items():
if key in query:
return val
return "未找到相关信息"
print(iterative_retrieve("张三的导师在哪所大学", fake_retriever))
真实系统中,迭代检索需要配合「停止判断」与「查询改写」模型,避免无效循环。
5.4 逻辑推理:符号推理与神经符号结合
对于需要严格逻辑的问题(如「A 大于 B,B 大于 C,则 A 与 C 的关系」),纯神经网络容易出错,符号推理更可靠。一个轻量方案是把自然语言转成结构化约束,再用约束求解器求解:
python
from itertools import permutations
# 示例:三人身高排序推理
people = ["A", "B", "C"]
constraints = [
lambda p: p.index("A") > p.index("B"), # A 比 B 高
lambda p: p.index("B") > p.index("C"), # B 比 C 高
]
valid = [p for p in permutations(people) if all(c(p) for c in constraints)]
print("从高到低排序:", valid)
# 输出:从高到低排序:[('A', 'B', 'C')]
神经符号结合(Neuro-Symbolic)是当前研究热点:神经网络负责语言理解与知识抽取,符号系统负责严格推理,两者互补。
6. 答案排序与生成层算法
6.1 答案融合:多源投票与加权
当多个模块给出多个候选答案时,需要融合。常用方法有投票法、加权打分法、学习排序(Learning to Rank):
python
from collections import Counter
def vote_fusion(answer_lists, weights=None):
"""answer_lists: 多个模块给出的答案列表"""
if weights is None:
weights = [1.0] * len(answer_lists)
counter = Counter()
for answers, w in zip(answer_lists, weights):
for ans in answers:
counter[ans] += w
return counter.most_common()
module_a = ["8848米", "8844米"]
module_b = ["8848米", "约8848米"]
module_c = ["8848米"]
print(vote_fusion([module_a, module_b, module_c], weights=[1.0, 0.8, 1.2]))
6.2 生成式回答:RAG 与提示工程
现代知识问答越来越多采用「检索增强生成」(Retrieval-Augmented Generation,RAG):先检索相关文档,再把文档作为上下文交给大模型生成答案。核心算法是「检索 + 拼接提示 + 生成」:
python
def build_rag_prompt(question, retrieved_docs):
context = "\n\n".join(f"[文档{i+1}] {d}" for i, d in enumerate(retrieved_docs))
return f"""请仅依据以下资料回答问题,若资料中没有答案,请回答"根据现有资料无法确定"。
资料:
{context}
问题:{question}
答案:"""
docs = [
"珠穆朗玛峰海拔约8848米,是世界最高峰。",
"珠穆朗玛峰位于中国与尼泊尔边界。",
]
print(build_rag_prompt("珠穆朗玛峰有多高", docs))
RAG 的关键工程点包括:检索质量、上下文长度控制、引用溯源、幻觉抑制。提示中明确「仅依据资料回答」是抑制幻觉的有效手段。
7. 算法选型对比与工程建议
把上述算法按维度对比,便于选型:
| 环节 | 算法 | 优点 | 局限 | 适用场景 |
|---|---|---|---|---|
| 意图识别 | TF-IDF + NB / BERT 微调 | 简单、快 | 长尾意图弱 | 意图类别少且稳定 |
| 实体识别 | CRF / BiLSTM-CRF / BERT-CRF | 边界准 | 需标注数据 | 领域实体抽取 |
| 稀疏检索 | BM25 | 无需训练、可解释 | 不懂同义 | 关键词明确的问题 |
| 稠密检索 | 双塔 + 向量库 | 语义泛化好 | 需训练、成本高 | 大规模语义召回 |
| 图检索 | SPARQL / 子图匹配 | 结构化、多跳强 | 依赖图谱质量 | 结构化知识库 |
| 混合检索 | RRF 融合 | 鲁棒、互补 | 需调参 | 通用生产系统 |
| 答案抽取 | MRC 模型 | 精确、可溯源 | 依赖段落召回 | 抽取式问答 |
| 精排 | Cross-Encoder | 精度高 | 无法预计算 | 少量候选重排 |
| 多跳推理 | 迭代检索 / CoT | 支持复杂问题 | 易累积误差 | 复杂关系问答 |
| 生成 | RAG + LLM | 表达自然 | 有幻觉风险 | 开放域问答 |
工程建议:
- 先跑通基线再优化:BM25 + MRC 就能覆盖大量事实型问题,不要一上来就上大模型。
- 召回与排序分离:召回追求召回率,排序追求精确率,两者用不同算法。
- 混合检索优先:稀疏 + 稠密 + RRF 是性价比最高的组合。
- 重视评估:用 Recall@K、MRR、EM、F1 分别评估检索与答案质量,避免只凭感觉调参。
- 幻觉治理:生成式方案必须配合引用溯源与「无答案」兜底。
7.1 检索与答案质量评估示例
工程建议里提到「用 Recall@K、MRR、EM、F1 分别评估检索与答案质量」,这里给出四个指标的最小可运行实现。先明确它们各自衡量的阶段:
- Recall@K:检索阶段指标,衡量前 K 个召回结果里是否包含正确文档,反映「召回得全不全」。
- MRR(Mean Reciprocal Rank):检索阶段指标,衡量正确文档在排序中的位置,反映「排得靠不靠前」。
- EM(Exact Match):答案阶段指标,衡量预测答案与标准答案是否完全一致,反映「答得准不准」。
- F1:答案阶段指标,衡量预测答案与标准答案的词级重叠,反映「答得全不全、准不准」的综合。
下面用构造好的小样本数据分别演示。
Recall@K:检索阶段
python
def recall_at_k(retrieved, relevant, k):
"""retrieved: 检索返回的文档 id 有序列表;relevant: 正确文档 id 集合"""
top_k = set(retrieved[:k])
hit = top_k & set(relevant)
return len(hit) / len(relevant) if relevant else 0.0
# 构造样本:3 个问题,每个问题有 1~2 个正确文档
retrieved_lists = [
["d1", "d3", "d2", "d5"], # 问题 1
["d2", "d1", "d4", "d3"], # 问题 2
["d6", "d7", "d8", "d9"], # 问题 3
]
relevant_sets = [
{"d1"},
{"d1", "d4"},
{"d6"},
]
for k in (1, 3):
scores = [recall_at_k(r, rel, k) for r, rel in zip(retrieved_lists, relevant_sets)]
avg = sum(scores) / len(scores)
print(f"Recall@{k} = {avg:.4f} 明细={scores}")
# 输出示例:
# Recall@1 = 0.5000 明细=[1.0, 0.0, 1.0]
# Recall@3 = 0.8333 明细=[1.0, 1.0, 1.0]
可以看到 K 越大召回越全,但 K 过大会把噪声也带进来,因此 Recall@K 通常与 MRR 一起看。
MRR:检索阶段
python
def reciprocal_rank(retrieved, relevant):
"""正确文档第一次出现位置的倒数,未命中记 0"""
for pos, doc_id in enumerate(retrieved, start=1):
if doc_id in relevant:
return 1.0 / pos
return 0.0
rr_scores = [reciprocal_rank(r, rel) for r, rel in zip(retrieved_lists, relevant_sets)]
mrr = sum(rr_scores) / len(rr_scores)
print(f"MRR = {mrr:.4f} 明细={[round(x, 4) for x in rr_scores]}")
# 输出示例:MRR = 0.5833 明细=[1.0, 0.5, 1.0]
MRR 只关心「第一个正确结果排第几」,适合答案通常只有一个正确文档的场景;若有多个正确文档,可改用 MAP 或 NDCG。
EM:答案阶段
python
import re
import string
def normalize(text):
"""统一小写、去标点、压缩空白,避免格式差异影响判定"""
text = text.lower()
text = text.translate(str.maketrans("", "", string.punctuation))
text = re.sub(r"\s+", " ", text).strip()
return text
def exact_match(pred, gold):
return 1.0 if normalize(pred) == normalize(gold) else 0.0
# 构造样本:预测答案 vs 标准答案
pred_answers = ["8848米", "北京", "政治、文化中心", "长江"]
gold_answers = ["8848米", "北京市", "政治、文化、国际交往和科技创新中心", "长江"]
em_scores = [exact_match(p, g) for p, g in zip(pred_answers, gold_answers)]
print(f"EM = {sum(em_scores) / len(em_scores):.4f} 明细={em_scores}")
# 输出示例:EM = 0.5000 明细=[1.0, 0.0, 0.0, 1.0]
EM 判定非常严格,「北京」与「北京市」也算错,因此它适合评估抽取式问答的精确性,但不适合评价生成式回答的语义正确性。
F1:答案阶段
python
from collections import Counter
def token_f1(pred, gold):
pred_tokens = normalize(pred).split()
gold_tokens = normalize(gold).split()
common = Counter(pred_tokens) & Counter(gold_tokens)
num_same = sum(common.values())
if num_same == 0:
return 0.0
precision = num_same / len(pred_tokens)
recall = num_same / len(gold_tokens)
return 2 * precision * recall / (precision + recall)
f1_scores = [token_f1(p, g) for p, g in zip(pred_answers, gold_answers)]
print(f"F1 = {sum(f1_scores) / len(f1_scores):.4f} 明细={[round(x, 4) for x in f1_scores]}")
# 输出示例:F1 = 0.6667 明细=[1.0, 0.0, 0.5, 1.0]
中文场景下按空格切词并不合适,工程中通常先分词(如 jieba)再计算词级 F1,或直接使用字符级 F1。F1 比 EM 更宽容,能部分反映「答对了一部分」的情况。
指标与阶段的对应关系
| 指标 | 衡量阶段 | 关注点 | 典型用途 |
|---|---|---|---|
| Recall@K | 检索阶段 | 前 K 个结果是否覆盖正确文档 | 评估召回器是否漏召 |
| MRR | 检索阶段 | 第一个正确结果的位置 | 评估排序质量 |
| EM | 答案阶段 | 答案是否完全一致 | 抽取式问答精确性 |
| F1 | 答案阶段 | 答案词级重叠程度 | 答案部分正确的评估 |
实践建议:检索阶段先用 Recall@K 保证召回率,再用 MRR 或 NDCG 优化排序;答案阶段用 EM 看严格正确率、用 F1 看整体质量,两者结合才能避免「只看一个指标被误导」。
8. 总结
知识问答模型不是单一算法,而是一套分层协作的算法体系:
- 问题理解层解决「问的是什么」,用分类、序列标注、规则与模型结合;
- 知识检索层解决「去哪里找」,用 BM25、双塔向量、图查询与混合融合;
- 答案抽取与推理层解决「怎么得到答案」,用 MRC、Cross-Encoder、迭代检索与符号推理;
- 答案排序与生成层解决「怎么给出好答案」,用投票融合、重排序与 RAG 生成。
理解每一层算法的原理与边界,比堆砌模型更重要。实际落地时,建议从简单基线出发,按评估指标逐步替换与增强,最终形成适合自身数据与场景的算法组合。