知识问答模型用到的算法集详解及代码实例

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 多跳推理:迭代检索与思维链

多跳问题(如「张三的导师所在大学的校长是谁」)无法一次检索解决。常见算法有两类:

  1. 迭代检索(Iterative Retrieval):把上一跳的答案作为下一跳的查询,循环检索。
  2. 思维链分解(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 生成。

理解每一层算法的原理与边界,比堆砌模型更重要。实际落地时,建议从简单基线出发,按评估指标逐步替换与增强,最终形成适合自身数据与场景的算法组合。

相关推荐
jyOverQ35 分钟前
我写了一个 CSDN Markdown 图片助手:告别本地图片逐张上传
python·工具·csdn
罗西的思考1 小时前
【轻量化复刻大模型 / KIMI K3】AttnRes 模块交互式演示
人工智能·算法·机器学习
刘马想放假4 小时前
CANviz 使用指南|Linux 安装、DBC 解码、实时曲线与 J1939 实战
linux·python·websocket
2601_967987375 小时前
python判断变量名不要调用任何函数实现
python·函数·语法·判断·变量名
好悬给我拽开线5 小时前
ZILOT 论文解析
算法
卷无止境5 小时前
SALib敏感性分析的python库学习笔记
后端·python
饮水桶_T6 小时前
JAVA IO流笔记
开发语言·数据结构·算法
duansamve6 小时前
Python3+Flask+PostgreSQL18+Vue3项目生产环境用docker如何部署?
python·docker·flask
特立独行的猫A7 小时前
少 3 个环节、少 1 次 ABI、少 3500 行代码:cj-tauri 与 Ionic/Capacitor 的鸿蒙桥接机制对比
java·后端·python