王仕宇RAG 实战教程(二):从 0 搭建一个可运行的 RAG 知识库——Embedding、Qdrant 与问答实战

上一篇《RAG 实战教程(一)》我们把 RAG 的完整工作原理拆了一遍:

text 复制代码
文档
 ↓
分片 Chunk
 ↓
Embedding
 ↓
向量索引
 ↓
召回 Retrieval
 ↓
重排 Rerank
 ↓
Context
 ↓
LLM
 ↓
Answer

但理解原理只是第一步。

这一篇我们不再停留在概念层面,而是直接动手:

从 0 写一个真正可以运行的 RAG 知识库。

我们会用:

text 复制代码
Python
+
OpenAI Compatible API
+
Embedding
+
Qdrant
+
LLM

完成这样一个效果:

text 复制代码
准备 Markdown / TXT 文档
        ↓
运行索引程序
        ↓
文档自动分片
        ↓
生成 Embedding
        ↓
写入 Qdrant
        ↓
用户输入问题
        ↓
向量检索相关知识
        ↓
拼接 Context
        ↓
交给大模型
        ↓
生成带来源的回答

最终可以直接这样使用:

bash 复制代码
python app.py

然后提问:

text 复制代码
请输入问题:RAG 为什么需要文本分片?

系统返回:

text 复制代码
根据知识库中的资料,RAG 进行文本分片主要有两个原因:

1. 大模型和 Embedding 模型都有上下文长度限制;
2. 较小的 Chunk 可以提高检索粒度,让系统更准确地找到与问题相关的内容。

来源:
- rag.md

这才是一个真正意义上的最小 RAG。


一、这次我们不使用 LangChain

很多 RAG 教程上来就是:

python 复制代码
from langchain...

然后十几行代码跑起来。

这种方式当然很方便,但对于第一次学习 RAG 的人来说,有一个问题:

你很容易"跑起来了",但不知道里面到底发生了什么。

所以这一篇我故意不使用:

text 复制代码
LangChain
LlamaIndex
Dify
RAGFlow

我们自己把几个关键模块写出来:

text 复制代码
Loader
Chunker
Embedding
VectorStore
Retriever
Prompt
Generator

这样你才能真正理解:

text 复制代码
一段文档到底是怎么进入向量数据库的?

Query 到底怎么进行检索?

Qdrant 里面保存的是什么?

Context 是怎么拼起来的?

大模型最终拿到的 Prompt 是什么?

等你理解之后,再使用各种 RAG 框架,反而会简单很多。


二、最终项目结构

先建立项目:

bash 复制代码
mkdir rag-demo
cd rag-demo

整个项目结构:

text 复制代码
rag-demo/
├── data/
│   ├── rag.md
│   └── ai.txt
│
├── config.py
├── loader.py
├── chunking.py
├── embedding.py
├── vector_store.py
├── indexer.py
├── rag.py
├── app.py
│
├── requirements.txt
├── .env
├── docker-compose.yml
└── README.md

各模块职责:

text 复制代码
loader.py
负责读取文档

chunking.py
负责文本分片

embedding.py
负责生成 Embedding

vector_store.py
负责操作 Qdrant

indexer.py
负责构建知识库索引

rag.py
负责检索 + Prompt + LLM

app.py
程序入口

这样后面继续增加:

text 复制代码
PDF
Word
BM25
Reranker
FastAPI
Vue

都不需要推倒重写。


三、创建 Python 虚拟环境

推荐使用 Python 3.11 或以上版本。

创建:

bash 复制代码
python -m venv .venv

macOS / Linux:

bash 复制代码
source .venv/bin/activate

Windows:

bash 复制代码
.venv\Scripts\activate

安装依赖:

bash 复制代码
pip install openai qdrant-client python-dotenv

创建:

text 复制代码
requirements.txt

内容:

txt 复制代码
openai
qdrant-client
python-dotenv

以后可以直接:

bash 复制代码
pip install -r requirements.txt

四、启动 Qdrant

这一篇不再使用第一篇里的:

python 复制代码
documents = []

这种内存 Vector Store。

我们直接上真正的向量数据库:

text 复制代码
Qdrant

Qdrant 是一个专门用于向量搜索的数据库,非常适合做:

text 复制代码
RAG
语义搜索
推荐系统
图片搜索
知识库
Agent Memory

开发环境最方便的方式就是 Docker。

直接运行:

bash 复制代码
docker run -d \
  --name qdrant \
  -p 6333:6333 \
  -p 6334:6334 \
  -v $(pwd)/qdrant_storage:/qdrant/storage \
  qdrant/qdrant

其中:

text 复制代码
6333

主要是 HTTP API。

text 复制代码
6334

主要是 gRPC。

启动以后访问:

text 复制代码
http://localhost:6333/dashboard

就可以看到 Qdrant Dashboard。


五、使用 Docker Compose 启动

我个人更推荐开发项目直接准备:

text 复制代码
docker-compose.yml

写入:

yaml 复制代码
services:

  qdrant:

    image: qdrant/qdrant:latest

    container_name: rag-qdrant

    restart: unless-stopped

    ports:
      - "6333:6333"
      - "6334:6334"

    volumes:
      - ./qdrant_storage:/qdrant/storage

启动:

bash 复制代码
docker compose up -d

查看:

bash 复制代码
docker compose ps

日志:

bash 复制代码
docker compose logs -f qdrant

停止:

bash 复制代码
docker compose down

注意:

bash 复制代码
docker compose down

不会删除:

text 复制代码
./qdrant_storage

里面的数据。

所以重新启动后知识库还在。


六、配置 API

创建:

text 复制代码
.env

写入:

env 复制代码
OPENAI_API_KEY=sk-xxxxxxxx

OPENAI_BASE_URL=https://api.openai.com/v1

CHAT_MODEL=gpt-5.6

EMBEDDING_MODEL=text-embedding-3-small

QDRANT_URL=http://localhost:6333

QDRANT_COLLECTION=rag_knowledge

这里有一点比较重要。

我们的代码使用:

python 复制代码
OpenAI(
    api_key=...,
    base_url=...
)

所以只要你的服务兼容 OpenAI API,一般都可以修改:

env 复制代码
OPENAI_BASE_URL

接入自己的模型服务。

例如:

text 复制代码
OpenAI
兼容 OpenAI 协议的中转服务
本地模型网关
统一模型 API Gateway

都可以按照这个思路接入。


七、统一读取配置

创建:

text 复制代码
config.py

代码:

python 复制代码
import os

from dotenv import load_dotenv


load_dotenv()


OPENAI_API_KEY = os.getenv(
    "OPENAI_API_KEY"
)

OPENAI_BASE_URL = os.getenv(
    "OPENAI_BASE_URL",
    "https://api.openai.com/v1"
)

CHAT_MODEL = os.getenv(
    "CHAT_MODEL",
    "gpt-5.6"
)

EMBEDDING_MODEL = os.getenv(
    "EMBEDDING_MODEL",
    "text-embedding-3-small"
)

QDRANT_URL = os.getenv(
    "QDRANT_URL",
    "http://localhost:6333"
)

QDRANT_COLLECTION = os.getenv(
    "QDRANT_COLLECTION",
    "rag_knowledge"
)

以后整个项目统一引用这里。


八、准备我们的第一份知识库

创建:

text 复制代码
data/rag.md

写一些内容:

markdown 复制代码
# 什么是 RAG?

RAG 全称 Retrieval-Augmented Generation,
中文通常翻译为检索增强生成。

RAG 会在大语言模型生成回答之前,
先从外部知识库中检索与用户问题相关的资料。

然后把这些资料和用户问题一起发送给大语言模型。

# 为什么需要 Chunk?

真实文档可能非常长。

如果直接把整篇文档转换成一个 Embedding,
检索粒度会非常粗。

所以 RAG 系统一般会先进行文本分片,
将大文档拆分成多个较小的 Chunk。

例如:

Chunk Size 可以设置为 500。

Overlap 可以设置为 50。

Overlap 可以降低句子或语义被分割边界截断的问题。

# 什么是 Embedding?

Embedding 可以将文字转换成高维向量。

语义越相近的文本,
对应的向量通常也越接近。

因此可以通过向量距离实现语义搜索。

# 什么是 Qdrant?

Qdrant 是一个向量数据库。

它可以保存 Embedding 向量以及对应的 Payload,
并且提供高效的向量相似度搜索能力。

在 RAG 系统中,
Qdrant 可以用于保存知识库 Chunk 的向量。

现在它就是我们的:

text 复制代码
Knowledge Base

九、实现文档 Loader

创建:

text 复制代码
loader.py

代码:

python 复制代码
from pathlib import Path


SUPPORTED_EXTENSIONS = {
    ".txt",
    ".md",
}


def load_file(path: str):

    file_path = Path(path)

    if not file_path.exists():

        raise FileNotFoundError(
            f"文件不存在: {path}"
        )

    if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:

        raise ValueError(
            f"暂不支持文件格式: {file_path.suffix}"
        )

    return file_path.read_text(
        encoding="utf-8"
    )


def load_directory(directory: str):

    directory_path = Path(directory)

    documents = []

    for path in directory_path.rglob("*"):

        if not path.is_file():
            continue

        if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
            continue

        content = load_file(
            str(path)
        )

        documents.append({
            "content": content,
            "source": str(path),
        })

    return documents

测试:

python 复制代码
from loader import load_directory


documents = load_directory(
    "./data"
)

for doc in documents:

    print(
        doc["source"]
    )

    print(
        doc["content"][:200]
    )

十、实现 Chunk 文本分片

创建:

text 复制代码
chunking.py

上一篇已经详细讲过 Chunk。

这里直接实现。

python 复制代码
def chunk_text(
    text: str,
    chunk_size: int = 500,
    overlap: int = 50
):

    if chunk_size <= 0:

        raise ValueError(
            "chunk_size 必须大于 0"
        )

    if overlap < 0:

        raise ValueError(
            "overlap 不能小于 0"
        )

    if overlap >= chunk_size:

        raise ValueError(
            "overlap 必须小于 chunk_size"
        )

    chunks = []

    start = 0

    text_length = len(text)

    while start < text_length:

        end = min(
            start + chunk_size,
            text_length
        )

        chunk = text[
            start:end
        ].strip()

        if chunk:

            chunks.append(
                chunk
            )

        if end >= text_length:
            break

        start += (
            chunk_size - overlap
        )

    return chunks

测试:

python 复制代码
from chunking import chunk_text


text = """
RAG 是一种检索增强生成技术。

它会在回答问题之前检索知识库。

知识库通常会进行文本分片,
然后生成 Embedding。
"""


chunks = chunk_text(
    text,
    chunk_size=50,
    overlap=10
)


for index, chunk in enumerate(chunks):

    print(
        f"Chunk {index}"
    )

    print(
        chunk
    )

    print(
        "-" * 50
    )

十一、不要只保存 Chunk 内容

真正写入向量数据库的时候,不应该只有:

json 复制代码
{
    "content": "..."
}

还应该保存 Metadata。

例如:

json 复制代码
{
    "content": "RAG 是一种检索增强生成技术。",
    "source": "data/rag.md",
    "chunk_index": 3
}

所以可以进一步封装:

python 复制代码
def chunk_document(
    document,
    chunk_size=500,
    overlap=50
):

    chunks = chunk_text(
        document["content"],
        chunk_size=chunk_size,
        overlap=overlap
    )

    results = []

    for index, chunk in enumerate(chunks):

        results.append({
            "content": chunk,
            "source": document["source"],
            "chunk_index": index,
        })

    return results

以后可以继续添加:

text 复制代码
title
page
author
department
category
created_at
updated_at
version
permission

这些 Metadata 在企业 RAG 中非常重要。


十二、实现 Embedding

现在进入 RAG 的核心。

创建:

text 复制代码
embedding.py

代码:

python 复制代码
from openai import OpenAI

from config import (
    OPENAI_API_KEY,
    OPENAI_BASE_URL,
    EMBEDDING_MODEL,
)


client = OpenAI(
    api_key=OPENAI_API_KEY,
    base_url=OPENAI_BASE_URL,
)


def create_embeddings(
    texts
):

    if isinstance(
        texts,
        str
    ):

        texts = [
            texts
        ]

    if not texts:

        return []

    response = client.embeddings.create(
        model=EMBEDDING_MODEL,
        input=texts,
    )

    return [
        item.embedding
        for item in response.data
    ]


def create_embedding(
    text: str
):

    return create_embeddings(
        [text]
    )[0]

测试:

python 复制代码
from embedding import create_embedding


vector = create_embedding(
    "什么是 RAG?"
)


print(
    type(vector)
)

print(
    len(vector)
)

print(
    vector[:10]
)

你会得到类似:

text 复制代码
[
    0.01234,
    -0.02341,
    0.00882,
    ...
]

这就是:

text 复制代码
Query Vector

十三、为什么要批量生成 Embedding?

不要这样写:

python 复制代码
for chunk in chunks:

    vector = create_embedding(
        chunk["content"]
    )

假设有:

text 复制代码
10000 个 Chunk

就可能产生:

text 复制代码
10000 次 API 请求

更合理的方法是批量:

python 复制代码
texts = [
    chunk["content"]
    for chunk in chunks
]

vectors = create_embeddings(
    texts
)

真实项目可以进一步做:

python 复制代码
BATCH_SIZE = 100

例如:

python 复制代码
def batch_list(
    data,
    batch_size
):

    for i in range(
        0,
        len(data),
        batch_size
    ):

        yield data[
            i:i + batch_size
        ]

使用:

python 复制代码
for batch in batch_list(
    chunks,
    100
):

    texts = [
        item["content"]
        for item in batch
    ]

    vectors = create_embeddings(
        texts
    )

这样效率会高很多。


十四、连接 Qdrant

接下来创建:

text 复制代码
vector_store.py

首先连接:

python 复制代码
from qdrant_client import (
    QdrantClient
)

from qdrant_client.models import (
    Distance,
    PointStruct,
    VectorParams,
)

from config import (
    QDRANT_URL,
    QDRANT_COLLECTION,
)


client = QdrantClient(
    url=QDRANT_URL
)

现在我们的 Python 已经可以连接:

text 复制代码
Qdrant

十五、创建 Collection

Qdrant 中和传统数据库:

text 复制代码
Table

比较类似的概念叫:

text 复制代码
Collection

我们创建一个:

text 复制代码
rag_knowledge

代码:

python 复制代码
def ensure_collection(
    vector_size: int
):

    if client.collection_exists(
        QDRANT_COLLECTION
    ):

        return

    client.create_collection(
        collection_name=QDRANT_COLLECTION,

        vectors_config=VectorParams(
            size=vector_size,
            distance=Distance.COSINE,
        ),
    )

这里:

python 复制代码
Distance.COSINE

代表使用:

text 复制代码
Cosine Similarity

进行向量相似度判断。


十六、为什么 Vector Size 必须一致?

假设 Embedding 模型产生:

text 复制代码
1536 维

向量。

那么 Collection 就必须配置对应维度。

不能:

text 复制代码
Collection = 768

然后写进去:

text 复制代码
1536

否则会报错。

所以我们这里不直接写死:

python 复制代码
1536

而是:

python 复制代码
vector = create_embedding(
    "测试"
)

vector_size = len(
    vector
)

这样即使以后更换 Embedding 模型,也更方便。


十七、写入 Qdrant

继续完善:

text 复制代码
vector_store.py

增加:

python 复制代码
def upsert_chunks(
    chunks,
    vectors
):

    points = []

    for index, (
        chunk,
        vector
    ) in enumerate(
        zip(chunks, vectors)
    ):

        points.append(
            PointStruct(
                id=index,
                vector=vector,
                payload={
                    "content": chunk["content"],
                    "source": chunk["source"],
                    "chunk_index": chunk["chunk_index"],
                }
            )
        )

    client.upsert(
        collection_name=QDRANT_COLLECTION,
        points=points,
    )

这里真正写入 Qdrant 的结构可以理解成:

json 复制代码
{
    "id": 1,

    "vector": [
        0.123,
        -0.332,
        0.781
    ],

    "payload": {
        "content": "RAG 是一种检索增强生成技术。",
        "source": "data/rag.md",
        "chunk_index": 0
    }
}

这里有两个部分非常重要:

text 复制代码
Vector

负责:

text 复制代码
检索

而:

text 复制代码
Payload

负责保存:

text 复制代码
真正的文档内容
来源
页码
分类
Metadata

十八、实现完整知识库索引

现在创建:

text 复制代码
indexer.py

把:

text 复制代码
文档读取
↓
Chunk
↓
Embedding
↓
Qdrant

全部串起来。

python 复制代码
from loader import load_directory

from chunking import (
    chunk_document
)

from embedding import (
    create_embeddings
)

from vector_store import (
    ensure_collection,
    upsert_chunks,
)


DATA_DIR = "./data"


def build_index():

    print(
        "1. 正在读取知识库..."
    )

    documents = load_directory(
        DATA_DIR
    )

    print(
        f"读取文档数量: {len(documents)}"
    )

    all_chunks = []

    print(
        "2. 正在进行文本分片..."
    )

    for document in documents:

        chunks = chunk_document(
            document,
            chunk_size=500,
            overlap=50,
        )

        all_chunks.extend(
            chunks
        )

    print(
        f"Chunk 数量: {len(all_chunks)}"
    )

    if not all_chunks:

        print(
            "没有可索引的内容"
        )

        return

    print(
        "3. 正在生成 Embedding..."
    )

    texts = [
        chunk["content"]
        for chunk in all_chunks
    ]

    vectors = create_embeddings(
        texts
    )

    print(
        f"Embedding 数量: {len(vectors)}"
    )

    vector_size = len(
        vectors[0]
    )

    print(
        f"向量维度: {vector_size}"
    )

    print(
        "4. 创建 Qdrant Collection..."
    )

    ensure_collection(
        vector_size
    )

    print(
        "5. 写入 Qdrant..."
    )

    upsert_chunks(
        all_chunks,
        vectors
    )

    print(
        "知识库索引构建完成!"
    )


if __name__ == "__main__":

    build_index()

现在执行:

bash 复制代码
python indexer.py

输出类似:

text 复制代码
1. 正在读取知识库...
读取文档数量: 2

2. 正在进行文本分片...
Chunk 数量: 18

3. 正在生成 Embedding...
Embedding 数量: 18
向量维度: 1536

4. 创建 Qdrant Collection...

5. 写入 Qdrant...

知识库索引构建完成!

这一步执行完:

我们的知识已经真正进入向量数据库了。


十九、查看 Qdrant 中的数据

打开:

text 复制代码
http://localhost:6333/dashboard

可以看到:

text 复制代码
Collections

其中应该出现:

text 复制代码
rag_knowledge

里面就保存了:

text 复制代码
Chunk 1
→ Vector 1

Chunk 2
→ Vector 2

Chunk 3
→ Vector 3

...

到这里:

text 复制代码
Indexing Pipeline

已经完成。


二十、接下来进入 Query Pipeline

前面执行的是离线流程:

text 复制代码
Document
 ↓
Chunk
 ↓
Embedding
 ↓
Qdrant

用户提问以后执行的是另外一套流程:

text 复制代码
Question
 ↓
Query Embedding
 ↓
Qdrant Search
 ↓
Top K
 ↓
Context
 ↓
LLM
 ↓
Answer

这两个流程一定要区分清楚。


二十一、实现向量召回

继续修改:

text 复制代码
vector_store.py

增加搜索:

python 复制代码
def search(
    query_vector,
    top_k=5
):

    result = client.query_points(
        collection_name=QDRANT_COLLECTION,

        query=query_vector,

        with_payload=True,

        limit=top_k,
    )

    return result.points

现在我们就拥有了:

text 复制代码
Vector Search

能力。


二十二、测试 Retrieval

创建一个临时测试:

python 复制代码
from embedding import (
    create_embedding
)

from vector_store import (
    search
)


query = "为什么 RAG 要进行分片?"


query_vector = create_embedding(
    query
)


results = search(
    query_vector,
    top_k=5
)


for result in results:

    print(
        "Score:",
        result.score
    )

    print(
        "Content:",
        result.payload["content"]
    )

    print(
        "Source:",
        result.payload["source"]
    )

    print(
        "=" * 80
    )

执行之后,可能得到:

text 复制代码
Score: 0.82

Content:
真实文档可能非常长。
如果直接把整篇文档转换成一个 Embedding,
检索粒度会非常粗。
所以 RAG 系统一般会先进行文本分片。

Source:
data/rag.md

这就意味着:

text 复制代码
Query

和这个 Chunk:

text 复制代码
语义很接近

所以 Qdrant 把它召回来了。


二十三、这一步就是 RAG 最核心的 Retrieval

整个过程实际上是:

text 复制代码
为什么 RAG 要进行分片?
        ↓
Embedding Model
        ↓
Query Vector
        ↓
Qdrant
        ↓
计算向量相似度
        ↓
Top 5

注意:

现在还没有调用大模型。

目前发生的一切都属于:

text 复制代码
Retrieval

很多 RAG 系统效果不好,真正的问题往往就发生在这里。


二十四、把召回结果转换成统一格式

我们不希望后面的业务代码直接依赖:

text 复制代码
Qdrant SDK Object

所以可以包装一下:

python 复制代码
def search_documents(
    query_vector,
    top_k=5
):

    points = search(
        query_vector,
        top_k
    )

    results = []

    for point in points:

        results.append({
            "score": point.score,

            "content":
                point.payload.get(
                    "content",
                    ""
                ),

            "source":
                point.payload.get(
                    "source",
                    ""
                ),

            "chunk_index":
                point.payload.get(
                    "chunk_index"
                ),
        })

    return results

这样后面的代码只面对:

python 复制代码
[
    {
        "score": 0.82,
        "content": "...",
        "source": "rag.md"
    }
]

更容易维护。


二十五、开始构造 Context

假设搜索得到:

text 复制代码
Top 5

我们需要把它们组装成:

text 复制代码
Context

例如:

text 复制代码
[资料 1]

来源:data/rag.md

内容:
RAG 系统一般会对长文档进行分片......

[资料 2]

来源:data/ai.txt

内容:
Embedding 可以把文字转换成向量......

创建:

text 复制代码
rag.py

首先实现:

python 复制代码
def build_context(
    documents
):

    blocks = []

    for index, doc in enumerate(
        documents,
        start=1
    ):

        block = f"""
[资料 {index}]

来源:
{doc["source"]}

内容:
{doc["content"]}
""".strip()

        blocks.append(
            block
        )

    return "\n\n".join(
        blocks
    )

二十六、为什么一定要保存 Source?

因为一个真正好用的 RAG 不应该只返回:

text 复制代码
答案

最好还能告诉用户:

text 复制代码
答案来自哪里?

例如:

text 复制代码
RAG 需要进行文本分片,是为了提高检索粒度,
同时避免单个文本过长。

来源:

《rag.md》

以后处理 PDF 的时候,还可以做到:

text 复制代码
来源:

《员工手册.pdf》第 18 页

这对于:

text 复制代码
企业知识库
法律文件
论文
内部制度
技术文档

非常重要。

因为用户需要:

验证大模型说的话到底有没有依据。


二十七、构造 RAG Prompt

继续:

text 复制代码
rag.py

增加:

python 复制代码
def build_prompt(
    query,
    context
):

    return f"""
你是一个专业的知识库问答助手。

请严格根据下面提供的知识库资料回答用户问题。

要求:

1. 只能优先依据提供的知识库内容回答;
2. 不要编造知识库中不存在的事实;
3. 如果知识库无法回答,请明确说明;
4. 回答尽量清晰、有条理;
5. 最后列出你参考的资料来源。

====================
知识库资料
====================

{context}

====================
用户问题
====================

{query}

====================
回答
====================
""".strip()

这里有一个非常重要的思想:

RAG 并不是把资料扔给模型就结束了。

Context 如何组织、Prompt 如何约束,同样会影响最终效果。


二十八、调用大模型生成答案

继续:

text 复制代码
rag.py

引入:

python 复制代码
from openai import OpenAI

from config import (
    OPENAI_API_KEY,
    OPENAI_BASE_URL,
    CHAT_MODEL,
)


llm_client = OpenAI(
    api_key=OPENAI_API_KEY,
    base_url=OPENAI_BASE_URL,
)

增加:

python 复制代码
def generate_answer(
    prompt
):

    response = llm_client.responses.create(
        model=CHAT_MODEL,
        input=prompt,
    )

    return response.output_text

这样:

text 复制代码
Retrieval

和:

text 复制代码
Generation

终于连起来了。


二十九、实现完整 RAG

继续:

text 复制代码
rag.py

完整:

python 复制代码
from openai import OpenAI

from config import (
    OPENAI_API_KEY,
    OPENAI_BASE_URL,
    CHAT_MODEL,
)

from embedding import (
    create_embedding
)

from vector_store import (
    search_documents
)


llm_client = OpenAI(
    api_key=OPENAI_API_KEY,
    base_url=OPENAI_BASE_URL,
)


def build_context(
    documents
):

    blocks = []

    for index, doc in enumerate(
        documents,
        start=1
    ):

        block = f"""
[资料 {index}]

来源:
{doc["source"]}

内容:
{doc["content"]}
""".strip()

        blocks.append(
            block
        )

    return "\n\n".join(
        blocks
    )


def build_prompt(
    query,
    context
):

    return f"""
你是一个专业的知识库问答助手。

请严格根据下面提供的知识库资料回答用户问题。

要求:

1. 优先依据知识库回答;
2. 不允许编造不存在的事实;
3. 如果知识库没有答案,请明确说明;
4. 给出简洁、准确、有条理的回答;
5. 在回答最后注明资料来源。

======== 知识库 ========

{context}

======== 用户问题 ========

{query}

======== 回答 ========
""".strip()


def generate_answer(
    prompt
):

    response = llm_client.responses.create(
        model=CHAT_MODEL,
        input=prompt,
    )

    return response.output_text


def ask(
    query,
    top_k=5
):

    # 1. Query Embedding
    query_vector = create_embedding(
        query
    )

    # 2. Retrieval
    documents = search_documents(
        query_vector,
        top_k=top_k
    )

    # 3. Context
    context = build_context(
        documents
    )

    # 4. Prompt
    prompt = build_prompt(
        query,
        context
    )

    # 5. Generation
    answer = generate_answer(
        prompt
    )

    return {
        "answer": answer,
        "documents": documents,
    }

我们的 RAG 核心代码已经完成。


三十、实现命令行问答

最后创建:

text 复制代码
app.py

代码:

python 复制代码
from rag import ask


def main():

    print(
        "=" * 60
    )

    print(
        "RAG 知识库问答系统"
    )

    print(
        "输入 exit 退出"
    )

    print(
        "=" * 60
    )

    while True:

        query = input(
            "\n请输入问题:"
        ).strip()

        if not query:
            continue

        if query.lower() in {
            "exit",
            "quit",
            "q"
        }:

            print(
                "Bye."
            )

            break

        result = ask(
            query,
            top_k=5
        )

        print(
            "\n回答:\n"
        )

        print(
            result["answer"]
        )

        print(
            "\n召回资料:"
        )

        for index, doc in enumerate(
            result["documents"],
            start=1
        ):

            print(
                f"\n[{index}] "
                f"score={doc['score']:.4f}"
            )

            print(
                f"source={doc['source']}"
            )


if __name__ == "__main__":

    main()

三十一、真正跑起来

整个项目启动分三步。

第一步:启动 Qdrant

bash 复制代码
docker compose up -d

确认:

bash 复制代码
docker compose ps

第二步:构建知识库

bash 复制代码
python indexer.py

第一次必须执行。

因为要完成:

text 复制代码
Document
 ↓
Chunk
 ↓
Embedding
 ↓
Qdrant

第三步:开始问答

bash 复制代码
python app.py

例如:

text 复制代码
请输入问题:什么是 RAG?

系统执行:

text 复制代码
什么是 RAG?
      ↓
Embedding
      ↓
Qdrant Search
      ↓
Top 5
      ↓
Context
      ↓
LLM
      ↓
Answer

最终回答:

text 复制代码
RAG 全称 Retrieval-Augmented Generation,
中文通常称为检索增强生成。

它的核心方式是在大模型回答用户问题之前,
先从外部知识库中检索与问题相关的信息,
然后将这些信息作为上下文提供给大模型。

这种方式可以让模型使用训练数据之外的知识,
同时降低回答脱离实际资料的概率。

来源:
data/rag.md

至此:

第一个真正完整的 RAG 已经跑起来了。


三十二、把 Debug 信息打印出来

做 RAG 时,我非常建议大家不要只打印:

text 复制代码
最终答案

而应该打印:

text 复制代码
Query
Retrieval Results
Score
Context
Prompt
Answer

例如:

python 复制代码
def debug_retrieval(
    documents
):

    print(
        "\n====== Retrieval ======"
    )

    for index, doc in enumerate(
        documents,
        start=1
    ):

        print(
            f"\nDocument {index}"
        )

        print(
            f"Score: {doc['score']}"
        )

        print(
            f"Source: {doc['source']}"
        )

        print(
            doc["content"][:500]
        )

为什么这么做?

假设用户问:

text 复制代码
Qdrant 有什么作用?

大模型回答错了。

不要第一反应就是:

text 复制代码
是不是 GPT 不够强?

先看:

text 复制代码
Retrieval

有没有找到:

text 复制代码
Qdrant 是一个向量数据库......

如果没有:

text 复制代码
这是检索问题。

如果已经找到了,大模型却回答错:

text 复制代码
才应该继续检查 Prompt 或 Generation。

这个 Debug 思路非常重要。


三十三、RAG 系统其实有两条 Pipeline

到这里应该很容易理解:

Indexing Pipeline

知识进入系统:

text 复制代码
Markdown
   ↓
Loader
   ↓
Chunk
   ↓
Embedding
   ↓
Vector
   ↓
Qdrant

一般:

text 复制代码
文档新增
文档修改
重新建立索引

的时候执行。


Query Pipeline

用户进行查询:

text 复制代码
Question
   ↓
Query Embedding
   ↓
Qdrant
   ↓
Top K
   ↓
Context
   ↓
Prompt
   ↓
LLM
   ↓
Answer

每次用户提问都会执行。

把这两条链路分清楚,基本就掌握了 RAG 项目的骨架。


三十四、我们为什么没有把整个文档直接交给模型?

有人可能会问:

我就这一两个 Markdown,为什么不直接全部发给 GPT?

因为我们现在练习的是可以扩展的架构。

现在可能只有:

text 复制代码
2 个文件

以后可能是:

text 复制代码
100 个文件
10000 个文件
10 万个 Chunk
100 万个 Chunk

真正的 RAG 应该做到:

text 复制代码
1000000 Chunk
        ↓
Retrieval
        ↓
Top 20
        ↓
Rerank
        ↓
Top 5
        ↓
LLM

而不是:

text 复制代码
把 100 万个 Chunk 全塞给 LLM。

三十五、当前版本还有哪些问题?

虽然我们的 RAG 已经可以运行,但它仍然只是:

text 复制代码
RAG V1

还有很多问题。

例如:

1. Chunk 方法比较简单

现在是:

text 复制代码
固定字符长度

后面可以升级成:

text 复制代码
按 Markdown 标题
按段落
按句子
按 Token
Semantic Chunking

目前:

text 复制代码
Query
 ↓
Embedding
 ↓
Vector Search

后面可以加入:

text 复制代码
BM25

形成:

text 复制代码
Vector Search
      +
BM25
      ↓
Hybrid Search

3. 没有 Reranker

现在:

text 复制代码
Vector Top 5
 ↓
LLM

更好的方案:

text 复制代码
Vector Top 20
 ↓
Reranker
 ↓
Top 5
 ↓
LLM

4. 只支持 Markdown 和 TXT

后面可以增加:

text 复制代码
PDF
Word
HTML
网页
Excel
数据库
GitHub

5. 没有权限控制

企业 RAG 还必须考虑:

text 复制代码
A 部门的数据
不能被 B 部门搜索

所以后面需要:

text 复制代码
Metadata Filter

三十六、下一步可以增加 Metadata Filter

例如我们写入:

json 复制代码
{
    "content": "...",
    "department": "finance",
    "source": "财务制度.pdf"
}

检索的时候,就可以限制:

text 复制代码
只搜索 finance

这样可以形成:

text 复制代码
Query
 ↓
Filter
 ↓
Vector Search

它会是企业知识库权限控制的重要基础。


三十七、为什么我更建议自己先写一遍?

我平时做后端、AI 工具和各种应用时,一个比较明显的体会是:

AI 时代很多框架会帮我们把代码变得越来越少,但理解底层链路反而越来越重要。

比如 RAG。

直接用框架:

python 复制代码
rag = SomeFramework(...)
rag.ask(...)

可能一天就能做出来。

但是客户问你:

text 复制代码
为什么这个答案没搜出来?

为什么知识库明明有这句话?

为什么两个文档搜索结果冲突?

为什么换 Embedding 模型后报维度错误?

为什么 Chunk Size 从 500 改成 1000 反而变差?

最后还是要回到底层:

text 复制代码
Loader
Chunk
Embedding
Vector
Retrieval
Rerank
Context
Generation

所以我更建议第一次做 RAG 的时候:

至少自己手写一遍最小实现。


三十八、我们这次到底完成了什么?

回顾一下。

首先准备原始知识:

text 复制代码
Markdown / TXT

然后:

text 复制代码
Loader

读取:

text 复制代码
Document

接着:

text 复制代码
Chunk

产生:

text 复制代码
Chunk 1
Chunk 2
Chunk 3
...

然后:

text 复制代码
Embedding Model

转换成:

text 复制代码
Vector 1
Vector 2
Vector 3
...

保存:

text 复制代码
Qdrant

用户提问之后:

text 复制代码
Query
 ↓
Query Embedding
 ↓
Qdrant Retrieval
 ↓
Top K
 ↓
Context
 ↓
LLM
 ↓
Answer

这已经是一个完整 RAG 系统的核心骨架。


三十九、完整流程图

整个项目最终可以画成:

text 复制代码
                     Indexing Pipeline

┌──────────────┐
│ Markdown/TXT │
└──────┬───────┘
       ↓
┌──────────────┐
│    Loader    │
└──────┬───────┘
       ↓
┌──────────────┐
│    Chunk     │
└──────┬───────┘
       ↓
┌──────────────┐
│  Embedding   │
└──────┬───────┘
       ↓
┌──────────────┐
│    Qdrant    │
└──────────────┘


                      Query Pipeline

┌──────────────┐
│ User Question│
└──────┬───────┘
       ↓
┌──────────────┐
│  Embedding   │
└──────┬───────┘
       ↓
┌──────────────┐
│    Qdrant    │
│ Vector Search│
└──────┬───────┘
       ↓
┌──────────────┐
│    Top K     │
└──────┬───────┘
       ↓
┌──────────────┐
│   Context    │
└──────┬───────┘
       ↓
┌──────────────┐
│    Prompt    │
└──────┬───────┘
       ↓
┌──────────────┐
│     LLM      │
└──────┬───────┘
       ↓
┌──────────────┐
│    Answer    │
└──────────────┘

这就是:

text 复制代码
RAG V1

四十、最后

如果你完整跟着这两篇做下来,现在应该已经不只是知道:

text 复制代码
"RAG 是检索增强生成。"

而是真的知道一个问题从进入系统开始,到最后生成答案,中间发生了什么:

text 复制代码
Question
 ↓
Embedding
 ↓
Retrieval
 ↓
Context
 ↓
Generation

第一篇,我们解决的是:

RAG 为什么能够工作?

第二篇,我们解决的是:

RAG 到底怎么跑起来?

但真实项目还有一个非常现实的问题:

text 复制代码
企业知识不是几个 TXT。

真实场景通常是:

text 复制代码
PDF
Word
Markdown
网页
技术文档
产品手册
公司制度
论文
各种复杂文件

特别是 PDF。

一个 PDF 里面可能还有:

text 复制代码
标题
段落
页码
表格
图片
页眉
页脚
扫描件

如何把这些东西正确解析成适合 RAG 的知识,其实又是一整个工程问题。

所以这个系列下一篇,我准备继续往真实项目推进。


RAG 实战教程系列

text 复制代码
第一篇
RAG 工作原理与完整流程
------分片、索引、召回、重排和生成

第二篇
从 0 搭建一个可运行的 RAG 知识库
------Embedding、Qdrant 与问答实战

第三篇
PDF、Word、Markdown 知识库实战
------文档解析、清洗与多格式数据接入

第四篇
Hybrid Search 实战
------Vector Search + BM25

第五篇
Reranker 重排实战
------为什么召回之后还需要重新排序

第六篇
Query Rewrite 与 Multi Query
------提升复杂问题的召回率

第七篇
RAG 评估体系
------Recall@K、MRR 与答案质量评估

第八篇
FastAPI + Vue
------把 RAG 做成真正的 Web 产品

第九篇
企业级 RAG
------权限、Metadata、增量索引与数据隔离

第十篇
Agentic RAG
------让 AI Agent 自己决定搜索什么

关于作者

我是王仕宇,JavaPub 作者,也是一名长期做后端开发、AI 应用和开源项目的技术创作者。

这几年一个很明显的感受是:

AI 正在让"写代码"越来越便宜,但把技术组合成一个真正可以使用的产品,依然需要理解工程、架构、业务和用户。

RAG 就是一个很典型的例子。

Demo 很容易。

真正做到:

text 复制代码
能搜到
搜得准
回答可信
来源可追溯
权限不串数据
文档可以持续更新

才是 RAG 真正开始产生价值的地方。

后面的文章,我也会继续以这种方式,把 RAG 从一个 Demo,一步一步做到真正能落地的项目。


现在是最好的时代。中国有全世界最高性价比的制造业,有发达的网络和全球物流。

只要你愿意,你几乎可以买到这个世界上任何地方生产的、任何你想要的商品。你可以用很低的成本,撬动全球的资源为你服务。

不过,真正稀缺的,从来不是商品,而是注意力、判断力,以及把事情做成的能力。

所以,这是最好的时代,也是最坏的时代。

我是王仕宇,关注 AI、Web3 与开源,持续探索如何把技术做成产品,把产品转化为真实价值。

JavaPub:

text 复制代码
https://javapub.net.cn/
相关推荐
Setsuna_F_Seiei1 小时前
前端的 AI 学习之路 01 之 Agent API 调用 - 和 Agent 的基础对话
前端·人工智能·ai编程
咔咔学姐kk2 小时前
小白程序员必收藏:轻松入门AI Agent开发,大厂校招新风口!
人工智能·深度学习·ai·程序员·大模型·就业·大模型学习
2601_967097222 小时前
园区巡检机器人推荐:4项评估维度与主流产品横评
大数据·人工智能·信息可视化
stuartevil2 小时前
零基础怎么用AI文生漫剧做出一条完整视频
人工智能·音视频
极客猴子2 小时前
能提取抖音视频文案的APP推荐:短视频文案工具合集
人工智能·智能手机·音视频·语音识别
sel_93 小时前
【强化学习】Hands-on Modern RL项目实践|OPD 算法完整解析
人工智能·深度学习·算法·机器学习·语言模型
xushichang123_3 小时前
Agentic AI 应用 Token 开销居高不下,可借助哪些云平台与架构实现成本管控?
人工智能
世岩清上3 小时前
新能源宣传片怎么拍?如何用画面讲好你的绿色能源故事
大数据·人工智能·能源·宣传片·宣传片拍摄
why技术4 小时前
eli5,我觉得这个全网在吹的技能,使用体验真的很一般啊。
前端·人工智能·后端