Tài liệu này dành cho developer muốn cho AI của EzyAI tìm và đọc thêm dữ
liệu ngoài bài viết và sản phẩm có sẵn, ví dụ dữ liệu sự kiện, khoá học,
câu hỏi thường gặp ở một bảng riêng, hoặc dữ liệu nằm ở một hệ thống bên
ngoài như vector database.
EzyAI cung cấp năm điểm mở rộng, từ đơn giản đến toàn quyền:
Điểm mở rộngDùng khiPhụ thuộc vào EzyAI
Internal event ezyai_knowledge_data_*_fetcherThêm một loại dữ liệu mới, không muốn phụ thuộc vào ezyai-sdkKhông
EzyAIKnowledgeDataFetcherThêm một loại dữ liệu mới, viết bằng Java trong plugin có ezyai-sdkCó
KnowledgeSearchStrategyThêm một cách tìm kiếm mới bên cạnh data indexCó
KnowledgeDataSourceThay toàn bộ cách tìm và đọc dữ liệu cho một profileCó
KnowledgeDataSourceProviderTạo nguồn dữ liệu động theo tên, ví dụ mỗi khách hàng một nguồnCó

Kiến trúc

Luồng tìm kiếm

flowchart TB
    AI["AI gọi tool<br/>search_knowledge_data_list"] --> H["SearchKnowledgeDataListToolEventHandler"]
    H -->|"dataSource = profile.aiDatasource"| M["KnowledgeDataSourceManager"]
    M -->|"bean KnowledgeDataSource<br/>có getName() trùng"| DS["KnowledgeDataSource"]
    M -.->|"không có bean trùng tên"| PV["KnowledgeDataSourceProvider<br/>provideKnowledgeDataSource(name)"]
    PV -.-> DS
    DS -->|"DEFAULT"| DDS["DefaultKnowledgeDataSource"]
    DDS --> SS["KnowledgeSearchStrategy<br/>(theo priority tăng dần)"]
    SS -->|"searchKnowledgeDataList"| KD["KnowledgeData"]
    SS -->|"searchDataTypeIdList"| TI["(dataType, dataId)"]
    TI --> F["EzyAIKnowledgeDataFetcher"]
    F --> F1["Post"]
    F --> F2["Product"]
    F --> F3["Event fetcher"]
    F3 -->|"ezyai_knowledge_data_list_fetcher"| EXT["Handler của plugin khác"]
    F1 & F2 & EXT --> KD
    KD --> CH["KnowledgeContentChunker"]
    CH --> R["Sắp xếp theo từ khoá,<br/>cắt theo limit"]

Các đối tượng chính

classDiagram
    class KnowledgeDataSourceManager {
        searchKnowledgeDataList(dataSource, query, parameters, limit)
        getKnowledgeDataByTypeAndIdOrCode(dataSource, type, id, code, parameters)
        getDataSourceByName(name)
        getKnowledgeDataSourceNames()
    }
    class KnowledgeDataSource {
        <<interface>>
        searchKnowledgeDataList(query, parameters, limit)
        getKnowledgeDataByTypeAndIdOrCode(type, id, code, parameters)
        getKnowledgeDataListByTypeAndIds(type, ids, parameters)
        getName()
        getDisplayName()
    }
    class KnowledgeDataSourceProvider {
        <<interface>>
        provideKnowledgeDataSource(name)
        getKnowledgeDataSourceNames()
    }
    class DefaultKnowledgeDataSource
    class KnowledgeSearchStrategy {
        <<interface>>
        searchDataTypeIdList(query, parameters, limit)
        searchKnowledgeDataList(query, parameters, limit)
        getPriority()
    }
    class EzyAIKnowledgeDataFetcher {
        <<interface>>
        getKnowledgeDataByTypeAndIdOrCode(type, id, code, parameters)
        getKnowledgeDataListByTypeAndIds(type, ids, parameters)
    }
    KnowledgeDataSourceManager --> KnowledgeDataSource
    KnowledgeDataSourceManager --> KnowledgeDataSourceProvider
    KnowledgeDataSource <|.. DefaultKnowledgeDataSource
    DefaultKnowledgeDataSource --> KnowledgeSearchStrategy
    DefaultKnowledgeDataSource --> EzyAIKnowledgeDataFetcher

KnowledgeData

Mọi điểm mở rộng đều trả về KnowledgeData:
TrườngBắt buộcÝ nghĩa
idCóId của dữ liệu, AI dùng để lấy chi tiết
typeCóLoại dữ liệu, ví dụ post, product, event
titleCóTiêu đề, hiển thị kèm link nguồn
slugKhôngMã định danh dạng chữ, AI có thể dùng thay id
contentKhôngNội dung đầy đủ, được chia chunk trước khi gửi cho AI
excerptKhôngĐoạn trích ngắn; khi chia chunk, EzyAI tự tạo đoạn trích cho từng chunk
chunkedKhôngtrue nếu dữ liệu đã được chia sẵn, EzyAI sẽ không chia lại
dataUrlKhôngĐường dẫn tới dữ liệu, tương đối hoặc tuyệt đối; đường dẫn tương đối được ghép với profileWebUrl
productCodeKhôngMã sản phẩm, khi dữ liệu là sản phẩm
price, currencyIsoCodeKhôngGiá và đơn vị tiền, chỉ trả trong tool lấy chi tiết

Tham số truyền vào

Khi AI gọi tool, EzyAI trộn tham số do AI sinh với context của lượt chat:
KhoáNguồnÝ nghĩa
query, limitAITừ khoá và số kết quả của search_knowledge_data_list
dataType, dataId, slug, productCodeAIĐịnh danh của search_knowledge_data_by_type_and_id
dataSourceProfileTên nguồn dữ liệu chọn ở profile
parametersProfileMap metadata của profile: rag_collection_name và các key khai báo ở ezyai_additional_ai_profile_meta_keys
profileName, profileWebUrlProfileTên và web url của profile
channelId, askerId, askerType, userIdLượt chatThông tin người hỏi
Các điểm mở rộng nhận được parameters, vì vậy có thể cấu hình riêng cho
từng profile qua metadata, ví dụ mỗi profile trỏ tới một collection khác
nhau của vector database.

Bean context

Các điểm mở rộng viết bằng Java được EzyAI tìm bằng
singletonFactory.getSingletonsOf(...) trong bean context nơi nó chạy:
  • Admin, web: các plugin admin dùng chung một bean context, các plugin
    web cũng vậy. Khai báo bean bằng @EzySingleton hoặc @Service trong
    plugin của bạn là EzyAI thấy được.
  • Socket: mỗi socket plugin có bean context và EventHandlerManager
    riêng. Bean hay event handler khai báo ở socket plugin khác sẽ không được
    ezyai-socket-plugin nhìn thấy, chỉ riêng các MCP tool (*_mcp_tool_*)
    được gom từ toàn zone. Chatbot nhúng qua ezychat chạy trong socket, cần
    lưu ý điểm này khi thiết kế.
Danh sách fetcher, strategy, data source được khởi tạo lười ở lần dùng đầu
tiên và giữ nguyên sau đó, nên bean phải có mặt từ lúc khởi động.

Thêm loại dữ liệu qua internal event

Cách này không cần phụ thuộc vào ezyai-sdk, phù hợp cho plugin muốn cung
cấp dữ liệu tri thức nhưng không muốn gắn chặt với EzyAI. DefaultKnowledgeDataSource
gọi hai internal event qua EzyAIEventKnowledgeDataFetcher:
EventTham sốKết quả
ezyai_knowledge_data_list_fetcherdataType, dataIds, parametersList các Map (hoặc KnowledgeData)
ezyai_knowledge_data_fetcherdataType, dataId, dataCode, parametersMột Map (hoặc KnowledgeData), null nếu không có
Các khoá của Map kết quả trùng tên các trường của KnowledgeData. Handler
phải trả về rỗng hoặc null với dataType không phải của mình.
Ví dụ plugin quản lý sự kiện cung cấp dữ liệu loại event:
@EzySingleton
@AllArgsConstructor
public class AdminEventKnowledgeDataListFetcherEventHandler
    extends AbstractEventHandler<Map<String, Object>, Object> {

    private final AdminEventRepository eventRepository;

    @SuppressWarnings("unchecked")
    @Override
    public Object handleEventData(Map<String, Object> data) {
        if (!"event".equals(data.get("dataType"))) {
            return Collections.emptyList();
        }
        Collection<Long> ids = (Collection<Long>) data.get("dataIds");
        return eventRepository.findListByIds(ids)
            .stream()
            .map(it -> EzyMapBuilder.mapBuilder()
                .put("id", it.getId())
                .put("type", "event")
                .put("title", it.getName())
                .put("slug", it.getSlug())
                .put("content", it.getDescription())
                .put("dataUrl", "/events/" + it.getSlug())
                .toMap()
            )
            .collect(Collectors.toList());
    }

    @Override
    public String getEventName() {
        return "ezyai_knowledge_data_list_fetcher";
    }
}
Làm tương tự cho ezyai_knowledge_data_fetcher để AI lấy được chi tiết một
sự kiện.
Để tool tìm kiếm tìm ra dữ liệu loại mới bằng data index của platform:
  1. Dữ liệu phải được đánh index trong DataIndexService với dataType
    trùng với dataType mà handler xử lý.
  2. Thêm dataType đó vào setting ezyai_knowledge_data_types (mặc định
    ezyarticle_posts, ecommerce_products).
Event chỉ được gọi khi đã có (dataType, dataId) do một search strategy
tìm ra. Nếu dữ liệu không có trong data index, cần thêm một
KnowledgeSearchStrategy như phần dưới.

Thêm loại dữ liệu bằng EzyAIKnowledgeDataFetcher

Tương đương với internal event nhưng viết trực tiếp bằng Java, kiểu dữ liệu
rõ ràng và không cần chuyển qua Map. DefaultKnowledgeDataSource gọi
tất cả fetcher:
  • getKnowledgeDataListByTypeAndIds: gộp kết quả của mọi fetcher.
  • getKnowledgeDataByTypeAndIdOrCode: lấy kết quả khác null đầu tiên.
@EzySingleton
@AllArgsConstructor
public class AdminCourseKnowledgeDataFetcher
    implements EzyAIKnowledgeDataFetcher {

    private static final String DATA_TYPE = "course";

    private final AdminCourseRepository courseRepository;

    @Override
    public KnowledgeData getKnowledgeDataByTypeAndIdOrCode(
        String type,
        Long id,
        String code,
        Map<String, Object> parameters
    ) {
        if (!DATA_TYPE.equals(type)) {
            return null;
        }
        Course course = id != null
            ? courseRepository.findById(id)
            : courseRepository.findByField("code", code);
        return course != null ? toKnowledgeData(course) : null;
    }

    @Override
    public List<KnowledgeData> getKnowledgeDataListByTypeAndIds(
        String type,
        Collection<Long> ids,
        Map<String, Object> parameters
    ) {
        if (!DATA_TYPE.equals(type)) {
            return Collections.emptyList();
        }
        return newArrayList(
            courseRepository.findListByIds(ids),
            this::toKnowledgeData
        );
    }

    private KnowledgeData toKnowledgeData(Course course) {
        return KnowledgeData.builder()
            .id(course.getId())
            .type(DATA_TYPE)
            .title(course.getName())
            .slug(course.getCode())
            .content(course.getDescription())
            .dataUrl("/courses/" + course.getCode())
            .build();
    }
}
Lưu ý:
  • Luôn kiểm tra type và trả về rỗng với loại không phải của mình, vì mọi
    fetcher đều được gọi.
  • Chỉ trả về dữ liệu được phép công khai cho người dùng chatbot (ví dụ đã
    xuất bản), vì nội dung sẽ được gửi cho nhà cung cấp AI và có thể hiển
    thị trong câu trả lời.
  • Truy vấn theo danh sách id một lần (findListByIds), không truy vấn từng
    id trong vòng lặp.

Thêm cách tìm kiếm bằng KnowledgeSearchStrategy

Search strategy quyết định dữ liệu nào khớp với từ khoá. Mặc định chỉ có
KnowledgeIndexedDataSearchStrategy, tìm trong data index của platform
bằng Reciprocal Rank Fusion, priority = Short.MAX_VALUE (chạy sau cùng).
Một strategy có thể trả kết quả theo một trong hai cách:
MethodTrả vềKhi nào dùng
searchKnowledgeDataListKnowledgeData đầy đủNguồn tìm kiếm trả luôn nội dung, ví dụ vector database, search engine
searchDataTypeIdList(dataType, dataId)Nguồn tìm kiếm chỉ trả id, nội dung được đọc sau bằng fetcher
Cách DefaultKnowledgeDataSource dùng các strategy:
sequenceDiagram
    participant DS as DefaultKnowledgeDataSource
    participant S as Strategies (priority tăng dần)
    participant F as Fetchers
    participant C as Chunker

    loop từng strategy
        DS->>S: searchKnowledgeDataList(query, parameters, limit)
        Note over DS: dừng sớm khi đã nhiều hơn limit kết quả
    end
    loop từng strategy
        DS->>S: searchDataTypeIdList(query, parameters, limit)
    end
    DS->>F: getKnowledgeDataListByTypeAndIds (gom theo dataType)
    DS->>C: chunk các dữ liệu chưa chunked
    DS->>DS: ưu tiên chunk chứa từ khoá, cắt theo limit
Ví dụ strategy tìm trong vector database, collection lấy từ metadata
rag_collection_name của profile:
@EzySingleton
@AllArgsConstructor
public class AdminVectorKnowledgeSearchStrategy
    implements KnowledgeSearchStrategy {

    private final AdminVectorSearchClient vectorSearchClient;

    @Override
    public List<KnowledgeData> searchKnowledgeDataList(
        String query,
        Map<String, Object> parameters,
        int limit
    ) {
        String collectionName = toStringOrNull(
            parameters.get(META_KEY_RAG_COLLECTION_NAME)
        );
        if (isBlank(collectionName)) {
            return Collections.emptyList();
        }
        return newArrayList(
            vectorSearchClient.search(collectionName, query, limit),
            it -> KnowledgeData.builder()
                .id(it.getId())
                .type(it.getType())
                .title(it.getTitle())
                .content(it.getText())
                .dataUrl(it.getUrl())
                .chunked(true)
                .build()
        );
    }

    @Override
    public int getPriority() {
        return 0;
    }
}
Lưu ý:
  • priority nhỏ chạy trước. Ở vòng searchKnowledgeDataList, khi tổng kết
    quả đã vượt limit thì các strategy sau bị bỏ qua.
  • Đặt chunked(true) nếu kết quả đã là đoạn ngắn, tránh bị chia lại.
  • Strategy được áp dụng cho nguồn DEFAULT, tức mọi profile chọn nguồn
    này. Hãy dựa vào parameters để quyết định có tìm hay không.

Thêm nguồn dữ liệu bằng KnowledgeDataSource

Khi cần thay hoàn toàn cách tìm và đọc dữ liệu cho một nhóm profile, cài
KnowledgeDataSource với tên riêng. Profile chọn nguồn này ở trường **Nguồn
dữ liệu**, EzyAI gọi thẳng nguồn đó mà không qua strategy, fetcher hay
chunker mặc định.
@EzySingleton
@AllArgsConstructor
public class AdminHelpCenterKnowledgeDataSource
    implements KnowledgeDataSource {

    private final AdminHelpCenterClient helpCenterClient;

    @Override
    public List<KnowledgeData> searchKnowledgeDataList(
        String query,
        Map<String, Object> parameters,
        int limit
    ) {
        return newArrayList(
            helpCenterClient.searchArticles(query, limit),
            this::toKnowledgeData
        );
    }

    @Override
    public KnowledgeData getKnowledgeDataByTypeAndIdOrCode(
        String type,
        Long id,
        String code,
        Map<String, Object> parameters
    ) {
        HelpCenterArticle article = helpCenterClient.getArticle(id, code);
        return article != null ? toKnowledgeData(article) : null;
    }

    @Override
    public List<KnowledgeData> getKnowledgeDataListByTypeAndIds(
        String type,
        Collection<Long> ids,
        Map<String, Object> parameters
    ) {
        return newArrayList(
            helpCenterClient.getArticles(ids),
            this::toKnowledgeData
        );
    }

    @Override
    public String getName() {
        return "HELP_CENTER";
    }

    @Override
    public String getDisplayName() {
        return "Help center";
    }

    private KnowledgeData toKnowledgeData(HelpCenterArticle article) {
        return KnowledgeData.builder()
            .id(article.getId())
            .type("help_center_article")
            .title(article.getTitle())
            .content(article.getBody())
            .dataUrl(article.getUrl())
            .build();
    }
}
Lưu ý:
  • getName() phải duy nhất, đây là giá trị lưu ở profile
    (ChatProfile.aiDatasource). Đổi tên sau khi đã có profile dùng sẽ làm
    các profile đó mất nguồn dữ liệu.
  • Trang chi tiết profile hiển thị nguồn dữ liệu bằng message key là tên
    viết thường (ví dụ help_center), hãy thêm key này vào file messages của
    plugin.
  • Nguồn tự lo việc giới hạn độ dài nội dung, vì không đi qua chunker mặc
    định. Có thể inject KnowledgeContentChunker để dùng lại.
  • Thông tin kết nối hệ thống ngoài (url, api key) đọc từ setting hoặc biến
    môi trường lúc chạy, không hardcode trong mã nguồn.

Tạo nguồn dữ liệu động bằng KnowledgeDataSourceProvider

Provider được hỏi khi không có bean KnowledgeDataSource nào trùng tên.
Dùng khi danh sách nguồn không cố định, ví dụ mỗi workspace của khách hàng
là một nguồn.
@EzySingleton
@AllArgsConstructor
public class AdminWorkspaceKnowledgeDataSourceProvider
    implements KnowledgeDataSourceProvider {

    private static final String PREFIX = "WORKSPACE_";

    private final AdminWorkspaceService workspaceService;
    private final AdminWorkspaceSearchClient searchClient;

    @Override
    public KnowledgeDataSource provideKnowledgeDataSource(
        String datasourceName
    ) {
        if (!datasourceName.startsWith(PREFIX)) {
            return null;
        }
        long workspaceId = toLongOrZero(
            datasourceName.substring(PREFIX.length())
        );
        return workspaceId > 0
            ? new WorkspaceKnowledgeDataSource(
                datasourceName,
                workspaceId,
                searchClient
            )
            : null;
    }

    @Override
    public List<KnowledgeDataSourceNameModel> getKnowledgeDataSourceNames() {
        return newArrayList(
            workspaceService.getActiveWorkspaces(),
            it -> KnowledgeDataSourceNameModel.builder()
                .name(PREFIX + it.getId())
                .displayName(it.getName())
                .build()
        );
    }
}
  • provideKnowledgeDataSource trả null với tên không phải của mình, các
    provider được hỏi lần lượt tới khi có kết quả.
  • getKnowledgeDataSourceNames cung cấp danh sách cho ô Nguồn dữ liệu
    ở form profile.
  • provideKnowledgeDataSource được gọi ở mỗi lần AI dùng tool, nên cần
    nhanh; cache đối tượng nếu khởi tạo tốn kém.

Kiểm tra

  1. Khởi động admin, mở form thêm/sửa profile, kiểm tra nguồn mới có trong ô
    Nguồn dữ liệu (với KnowledgeDataSource và provider).
  2. Gắn vào profile một kỹ năng có tool search_knowledge_data_list và
    search_knowledge_data_by_type_and_id.
  3. Chat thử ở trang chi tiết dịch vụ với câu hỏi liên quan tới dữ liệu mới.
    Câu trả lời trả về metadata knowledgeList liệt kê các dữ liệu AI đã
    dùng, kiểm tra type, id, dataUrl đúng như mong đợi.
  4. Nếu AI không tìm thấy dữ liệu:
    Kiểm traCách xử lý
    Profile đã chọn đúng nguồn dữ liệuSửa profile
    dataType có trong ezyai_knowledge_data_typesThêm vào setting
    Dữ liệu đã có trong data indexĐánh index lại, hoặc thêm search strategy
    Fetcher/handler có bỏ qua do sai typeSo sánh dataType trả về từ strategy với type mà fetcher kiểm tra
    Bean nằm ở socket plugin khácChuyển sang admin/web hoặc vào cùng plugin với EzyAI
    Kiểm traCách xử lý
    Profile đã chọn đúng nguồn dữ liệuSửa profile
    dataType có trong ezyai_knowledge_data_typesThêm vào setting
    Dữ liệu đã có trong data indexĐánh index lại, hoặc thêm search strategy
    Fetcher/handler có bỏ qua do sai typeSo sánh dataType trả về từ strategy với type mà fetcher kiểm tra
    Bean nằm ở socket plugin khácChuyển sang admin/web hoặc vào cùng plugin với EzyAI